The h1 + tagline at the top of the main view were just taking
vertical space. Strip them. The "Finrod" wordmark now lives only
inside the side drawer, set in Cinzel (classical-inscription
serif, loaded from Google Fonts) alongside the Star of Fëanor
mark — matches the favicon and the loremaster vibe.
Body padding-top adjusted to 3.5 rem so the controls clear the
fixed hamburger button at the top-left.
Replace the <details>-based kebab popover with a proper Material-
style navigation drawer:
- Hamburger button (menu icon) at top-left.
- <aside> drawer slides in from the left via transform: translateX().
width: min(85vw, 320px). Backdrop overlay behind it.
- Close paths: X button in the drawer header, backdrop click, Esc.
- Padding honours Android safe-area insets on all four sides.
- Model selector + label live inside the drawer; future per-app
settings can move here too.
- Controls go 3 cols → 2 cols (From / To). Model selector moves
into a kebab (⋮ more_vert) menu in the top-left corner, using
a native <details> element so toggle and a11y come for free.
- Default source language: Dutch (was Auto-detect). Target stays
English. Quickest path to a translation since the typical use
case is reading Dutch text.
- Drop the VisualViewport listener and --kb-inset CSS var —
Android Chrome already handles keyboard with the
interactive-widget=resizes-content meta directive; iOS Safari
isn't in scope. FAB bottom calc reverts to just safe-area-inset.
Two-pronged fix so the Translate + Scan FABs stay reachable when
the mobile soft keyboard is open:
- Add `interactive-widget=resizes-content` to the viewport meta —
Chrome on Android then shrinks the layout viewport when the
keyboard opens, so `position: fixed; bottom: ...` is naturally
above it. iOS Safari ignores this directive, hence:
- VisualViewport listener computes
inset = innerHeight - vv.height - vv.offsetTop
and writes it to a `--kb-inset` CSS custom property. The FAB
bottom calc uses `max(var(--kb-inset), safe-area-inset-bottom)`
so the keyboard inset wins when it's open and the home-indicator
inset wins when it isn't.
Net: tap the editor on iOS → keyboard slides up → Translate /
Scan FABs rise with it, no manual minimise required.
Promotes Translate from an inline button under the input pane to
the primary 56x56 FAB at the bottom-right corner. Camera FAB
becomes the secondary in the stack, just above. Both share a
single .fab base class; positioning via .fab-primary /
.fab-secondary.
- Translate icon: Material Symbols `translate` (28 px).
- Pulsing-ring `busy` animation while a translation runs, since
the icon-only button can't show "Translating…" text anymore.
- body padding-bottom bumped to ~9 rem so the output pane
scrolls clear of the stacked FABs.
- ⌘/Ctrl-Enter still triggers translate via the existing
CodeMirror keymap; tooltip text updated.
Previous attempt was actually-broken — typo notwithstanding, the
mix of 100vh, no explicit flex-shrink, and inconsistent body
padding was easy for the body to grow and shove the footer
buttons offscreen.
This pass:
- 100dvh instead of 100vh (mobile chrome / address bar collapses
don't cause overflow / under-shoot)
- flex-shrink: 0 on .modal-header and .modal-footer so the body
can never squeeze them out
- explicit background on the footer so it's not transparent
against whatever's behind
- camera <video> fills modal-body with object-fit: contain
- body padding kept just enough (~16 px) for crop handle safety
@media (max-width: 640px):
- backdrop loses its 1 rem inset; modal goes 100vw × 100vh with
no border-radius
- crop modal body keeps ~20 px horizontal padding so cropper
corner handles stay safely outside Android's edge-swipe
back-gesture zone (the corner DRAG is what triggers it; a tap
on a button at the edge doesn't)
- camera viewfinder body has zero padding — video fills the
whole space
- footer has minimal 0.5 rem padding so the rotate / invert /
cancel / check buttons hug the screen edges
- iOS notch / home-indicator insets honoured via
env(safe-area-inset-*)
Doesn't change the camera-confirm behaviour — that's the OS-camera
fallback path that fires when getUserMedia is denied for not being
a secure context (i.e. http://lan-ip:8000 from the phone, not
HTTPS or localhost).
Four wins:
1. Replace <input type=file capture> with a getUserMedia
viewfinder modal on mobile. Tapping the shutter goes straight
to the crop modal — skips the OS "retake/use" confirm step.
Falls back to file picker if camera permission is denied or
the API is unavailable. "Pick from file" icon
(photo_library) inside the camera modal opens the gallery
on demand.
2. Drop the Reset button — with no default crop box, "reset" is
just "no box again," which is the starting state anyway.
3. The check (Use this) button is now larger and more prominent
— wider padding, 24px icon, accent fill.
4. Invert-colours toggle (invert_colors icon) in the crop modal.
White-on-dark text (signs, menus, screenshots) breaks
Tesseract because the binariser was made for ink on paper.
Toggling the button flips the grayscale buffer before
adaptive thresholding so the binary output ends up
black-on-white. State plumbed cleanly: openCropper now
resolves with {blob, invert}; preprocessForOCR takes an
{invert} option; binarizeAdaptive applies the flip.
Also: drop `capture=environment` from the file <input>. Without
it the file path correctly opens the gallery/file dialog instead
of re-opening the OS camera when the user picks "Pick from file".
- Material Symbols Outlined webfont (Google Fonts) — used for the
FAB camera (photo_camera), and the rotate_left / rotate_right /
restart_alt / check buttons in the crop modal. Replaces the
platform-rendered 📷 emoji that was off-centre, and replaces
text labels on rotate/use buttons. Cancel stays text.
- Cropper: autoCrop=false — no default selection. User drags on
the image to draw the crop region; if they "Use this" without
drawing, falls back to the full (possibly rotated) image.
- Modal-body padding bumped from 1.25 rem to 2 rem so the
bottom-right corner handle sits ~48 px from the screen edge,
outside Android's edge-swipe back-gesture zone.
- Header copy updated to "Drag on the image to select the text
region" since there's no longer a default box to convey intent.
- Scan button is now a 📷 floating action button at bottom-right
of the viewport (camera emoji per explicit request). Replaces
the small "Scan image" button in the input header.
- Crop modal: inner padding on .modal-body so cropperjs's
bottom-right corner handle isn't on top of Android's edge-swipe
back-gesture zone. Backdrop also respects iOS safe-area insets
(env(safe-area-inset-{right,left,bottom})).
After picking an image, a modal opens with cropperjs's drag-handle
crop UI and Rotate left / Rotate right / Reset buttons. Cropping
to just the text region is the single biggest quality win for
OCR on phone photos — strips out logos, lighting transitions, and
background junk that bleed past the confidence filter.
- Lazy-loads cropperjs + its CSS from esm.sh on first use (no
bundling; same pattern as Tesseract.js and CodeMirror).
- Honours EXIF orientation from phone shots (checkOrientation).
- Keyboard: Esc cancels, Enter accepts. Clicking the backdrop
also cancels.
- Output goes through the existing preprocessForOCR pipeline
(downscale to 1600px + Bradley adaptive threshold) before
Tesseract sees it.
Two layers to stop tesseract hallucinating text on phone photos:
1. Bradley adaptive threshold (O(N) via integral images) turns the
downscaled grayscale image into a binary B/W mask. Cuts out
gradients, shadows, and surface texture that tesseract
misreads as ink.
2. After recognise(), filter data.words by confidence >= 60 and
re-assemble lines from kept words. Status line surfaces how
many were dropped so the cost is visible.
Output of preprocess stage is PNG (not JPEG) so the sharp B/W
edges don't get re-blurred.
2026-06-09 18:54:27 +02:00
3 changed files with 683 additions and 67 deletions
- "Scan image" button next to the input pane: opens the phone's rear camera (or a file picker on desktop), runs OCR locally in the browser via Tesseract.js (loaded from esm.sh on first use), and replaces the editor contents with the recognised text. Language follows the "From" dropdown via an ISO 639-2 mapping; "Auto-detect" loads a common European set (`eng+por+deu+nld`). Progress status shows beneath the input ("Loading OCR engine…" → "OCR 40%" → "OCR done in 3.2s"). Zero image-size cost — same CDN pattern as CodeMirror.
- Images are downscaled to 1600 px on the long edge before OCR. A 12 MP phone photo (~4000 px) drops to ~1.3 MP, making Tesseract 4-8× faster with no real accuracy loss for printed text. Smaller-than-1600 px inputs are passed through unchanged.
- Adaptive thresholding (Bradley's method with integral images) binarises the downscaled image before OCR — drops hallucinated text from shadows, gradients, and surface texture on phone photos. Output is pure black-on-white, much closer to the scanned-page input Tesseract was trained on.
- Per-word confidence filter (≥ 60) drops the low-confidence noise Tesseract still emits. Re-assembles lines from kept words; status line reports how many words were dropped.
- Crop & rotate modal between image pick and OCR. After choosing an image, a modal pops up with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. EXIF orientation is honoured automatically. Cancel / `Esc` / clicking the backdrop aborts; `Enter` accepts. Library lazy-loaded from esm.sh (no bundling).
### Changed
- "Scan image" button replaced with a floating action button at the bottom-right of the viewport, using a Material Symbols Outlined camera glyph (`photo_camera`) for consistent rendering across platforms (was a small text button in the input pane header).
- "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).
- Layout: From / To dropdowns now occupy 2 columns at the top (was 3 columns including Model).
- 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.
- Page header (`h1 finrod` + tagline) removed from the main view to recover vertical space. The "Finrod" wordmark now lives only inside the side drawer, set in Cinzel (classical-inscription serif, loaded from Google Fonts) alongside the Star of Fëanor mark.
- Default source language is now Dutch (was Auto-detect). Target stays English.
- 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.
- Buttons are now Material Symbols icons: `rotate_left`, `rotate_right`, `invert_colors` (new — see below), `check`. Cancel kept as text. The `check` (Use this) button is larger and more prominent. The Reset button was removed (rarely useful with no default box).
- Inner padding bumped to 2 rem so cropperjs's corner handles never sit on the right/bottom edges where Android's edge-swipe back gesture fires. iOS safe-area insets honoured on the backdrop and FAB.
- Header text is now a hint ("Drag on the image to select the text region") instead of a title.
### Added
- Camera viewfinder modal on mobile (via `navigator.mediaDevices.getUserMedia` with `facingMode: environment`) — replaces the `<input type=file capture>` flow. Tapping the shutter goes directly to the crop modal, skipping the OS-level "retake / use" confirm step. Falls back to the file picker if camera access is denied or unavailable; a "Pick from file" icon (`photo_library`) inside the camera modal also routes to the picker on demand.
- Invert-colours toggle (`invert_colors`) in the crop modal. Tap to flip light-on-dark text into the dark-on-light polarity Tesseract expects. The grayscale buffer is inverted before adaptive thresholding so the binarised output is correct.
- Mobile-first modal layout (≤ 640 px): the crop and camera modals now fill the viewport (no backdrop padding, no border-radius), buttons hug the screen edges in the footer, and the camera video occupies the entire body. Body still has ~20 px horizontal padding so cropper's corner handles stay outside Android's edge-swipe back-gesture zone. iOS notch / home-bar insets honoured.
### Fixed
- "Hot: …" status no longer claims "unloads in X" when the server's `keep_alive` is `-1` (Ollama returns an absurd year-4001 `expires_at`). Threshold: any expiry >7 days drops the suffix and just shows the model name.
<button class="modal-btn icon" data-act="invert" title="Invert colours (for light text on dark background)"><span class="material-symbols-outlined">invert_colors</span></button>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.