v0.3.0: OCR via camera, FAB UX, side drawer #4

Merged
jpnadas merged 16 commits from feature/ocr-camera into main 2026-06-09 18:38:34 +00:00
2 changed files with 789 additions and 13 deletions
Showing only changes of commit 1fffe8b7e1 - Show all commits

View File

@@ -19,7 +19,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- "Translate" inline button replaced with a primary FAB using the `translate` Material Symbols icon, stacked directly under the camera FAB at the bottom-right corner (closest to the thumb on phones). The camera FAB is now the *secondary* in the stack, just above. Both 56 × 56 px, same accent fill. The translate FAB gains a pulsing-ring `busy` animation during a running translation; `goBtn.textContent` swap is gone (was meaningless for an icon-only button). `body` got `~9 rem` of bottom padding so content scrolls clear of the stack. - "Translate" inline button replaced with a primary FAB using the `translate` Material Symbols icon, stacked directly under the camera FAB at the bottom-right corner (closest to the thumb on phones). The camera FAB is now the *secondary* in the stack, just above. Both 56 × 56 px, same accent fill. The translate FAB gains a pulsing-ring `busy` animation during a running translation; `goBtn.textContent` swap is gone (was meaningless for an icon-only button). `body` got `~9 rem` of bottom padding so content scrolls clear of the stack.
- FAB stack rides above the on-screen keyboard on mobile via `interactive-widget=resizes-content` in the viewport meta tag (handled by Chrome on Android). - FAB stack rides above the on-screen keyboard on mobile via `interactive-widget=resizes-content` in the viewport meta tag (handled by Chrome on Android).
- Layout: From / To dropdowns now occupy 2 columns at the top (was 3 columns including Model). - Layout: From / To dropdowns now occupy 2 columns at the top (was 3 columns including Model).
- Model selector moved into a kebab (⋮) menu in the top-left corner. Uses a native `<details>` element so toggle/keyboard-accessibility works without extra JS. - Model selector moved into a slide-out side drawer (hamburger menu in the top-left). Drawer opens from the left with a backdrop, closes via the X button, backdrop click, or `Esc`. Honours Android safe-area insets.
- Default source language is now Dutch (was Auto-detect). Target stays English. - Default source language is now Dutch (was Auto-detect). Target stays English.
- Crop modal: - Crop modal:
- No default crop box. User drags on the image to draw the region; falls back to full image if they confirm without drawing. - No default crop box. User drags on the image to draw the region; falls back to full image if they confirm without drawing.

View File

@@ -51,44 +51,94 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Kebab menu (top-left) housing the Model dropdown — keeps the main /* Hamburger menu (top-left) → slide-out side drawer that houses the
controls focused on the From/To pair. */ Model selector (and future per-app settings). */
.kebab { .drawer-toggle {
position: fixed; position: fixed;
top: calc(1rem + env(safe-area-inset-top, 0px)); top: calc(1rem + env(safe-area-inset-top, 0px));
left: calc(1rem + env(safe-area-inset-left, 0px)); left: calc(1rem + env(safe-area-inset-left, 0px));
z-index: 50;
}
.kebab > summary {
list-style: none;
width: 40px; width: 40px;
height: 40px; height: 40px;
padding: 0;
margin: 0;
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel); background: var(--panel);
color: var(--fg); color: var(--fg);
z-index: 50;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
} }
.kebab > summary::-webkit-details-marker { display: none; } .drawer-toggle .material-symbols-outlined { font-size: 22px; }
.kebab > summary::marker { display: none; }
.kebab > summary .material-symbols-outlined { font-size: 22px; } .drawer-backdrop {
.kebab[open] > summary { border-color: var(--accent); color: var(--accent); } position: fixed;
.kebab-panel { inset: 0;
position: absolute; background: rgba(0, 0, 0, 0.55);
top: calc(100% + 0.5rem); opacity: 0;
left: 0; pointer-events: none;
background: var(--bg); transition: opacity 0.2s;
border: 1px solid var(--border); z-index: 200;
border-radius: 8px;
padding: 0.75rem;
min-width: 220px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
} }
.kebab-panel label { .drawer-backdrop.open { opacity: 1; pointer-events: auto; }
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(85vw, 320px);
background: var(--bg);
border-right: 1px solid var(--border);
padding:
max(1rem, env(safe-area-inset-top, 0px))
1.25rem
max(1rem, env(safe-area-inset-bottom, 0px))
max(1.25rem, env(safe-area-inset-left, 0px));
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 201;
box-shadow: 4px 0 14px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.drawer.open { transform: translateX(0); }
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.drawer-header h2 {
margin: 0;
font-size: 1rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--muted);
text-transform: uppercase;
}
.drawer-close {
background: transparent;
border: none;
color: var(--muted);
width: 32px;
height: 32px;
padding: 0;
margin: 0;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.drawer-close:hover { color: var(--fg); background: var(--panel); }
.drawer-close .material-symbols-outlined { font-size: 20px; }
.drawer label {
display: block; display: block;
font-size: 0.7rem; font-size: 0.7rem;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -432,13 +482,22 @@
</style> </style>
</head> </head>
<body> <body>
<details class="kebab"> <button type="button" id="drawer-toggle" class="drawer-toggle" aria-label="Open menu" title="Menu">
<summary aria-label="Settings" title="Settings"><span class="material-symbols-outlined">more_vert</span></summary> <span class="material-symbols-outlined">menu</span>
<div class="kebab-panel"> </button>
<div id="drawer-backdrop" class="drawer-backdrop"></div>
<aside id="drawer" class="drawer" aria-hidden="true">
<div class="drawer-header">
<h2>Settings</h2>
<button type="button" id="drawer-close" class="drawer-close" aria-label="Close menu" title="Close">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div>
<label for="model">Model</label> <label for="model">Model</label>
<select id="model"></select> <select id="model"></select>
</div> </div>
</details> </aside>
<div class="wrap"> <div class="wrap">
<h1>finrod</h1> <h1>finrod</h1>
<div class="sub">Translation via Ollama · loremaster of tongues</div> <div class="sub">Translation via Ollama · loremaster of tongues</div>
@@ -1189,6 +1248,28 @@
Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" });
} }
// Side drawer (Settings) wiring — opens from the top-left hamburger.
const drawerEl = document.getElementById("drawer");
const drawerBackdrop = document.getElementById("drawer-backdrop");
const drawerToggle = document.getElementById("drawer-toggle");
const drawerCloseBtn = document.getElementById("drawer-close");
function openDrawer() {
drawerEl.classList.add("open");
drawerBackdrop.classList.add("open");
drawerEl.setAttribute("aria-hidden", "false");
}
function closeDrawer() {
drawerEl.classList.remove("open");
drawerBackdrop.classList.remove("open");
drawerEl.setAttribute("aria-hidden", "true");
}
drawerToggle.addEventListener("click", openDrawer);
drawerCloseBtn.addEventListener("click", closeDrawer);
drawerBackdrop.addEventListener("click", closeDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerEl.classList.contains("open")) closeDrawer();
});
fillLangs(); fillLangs();
loadModels().then(() => refreshLoaded(true)); loadModels().then(() => refreshLoaded(true));
</script> </script>