Native-feel pass: PWA integration, system back, offline, haptics, undo

Mobile only (<=639px / coarse pointers); desktop keeps its behaviour.

System integration
- Manifest: id, display_override, monochrome icon, Make my homework
  shortcut and a share target (text/URL via GET, files via POST).
  iOS startup images for the common iPhone sizes. Pinch-zoom is allowed
  again (double-tap zoom is disabled via touch-action instead).
- Share target: /share chooser with New deck / Make my homework; POSTed
  files wait in a 10-minute in-memory store, reach the wizard through
  sessionStorage and never touch disk.
- Web Share: Share rows for decks (JSON file where supported) and the
  homework result, with a clipboard fallback and a toast.
- Offline: a quiet pill appears and Freeze / mode cards are disabled
  with a clear reason; input is never lost (error cards keep Retry).
- State: wizard drafts (text, options, photos up to a size budget)
  survive reloads and OS kills and are cleared on success; re-tapping
  the active tab scrolls to the top; the deck page shows a skeleton
  instead of a spinner.

Navigation and gestures
- System Back closes the topmost layer first: a small history-entry
  stack (lib/use-back-close.ts) covers the drawer, Settings sheet,
  install sheet, deck actions panel and one wizard step; navigations
  are never rewound and stale entries are skipped.
- Destructive-but-reversible actions use Undo toasts: deleting a deck
  (no confirm) and removing a file in the wizard. Irreversible ones
  keep their confirm.

Input and feedback
- Every phone input got inputmode / enterkeyhint / autocomplete /
  autocapitalize / spellcheck; Enter on the last field advances the
  wizard; a clipboard-paste button appears where the API exists.
- Haptics behind a Settings toggle (on by default): tab switches,
  toggles, correct/wrong, completion; Vibration API + the iOS hidden
  switch tick, throttled and silent everywhere else.
- Wake lock during the study feed and homework guide, released on
  leave and re-acquired when the tab returns.

Verified: new native suite 35/35 (manifest/head/viewport, offline,
share target GET + POST, back-gesture layers, undo, share fallback,
tab re-tap, haptics persistence, draft restore), regression 74/74,
mobile 52/52, wizard 80/80, declutter 116/116, animation 8/8,
i18n 25/25. Lint + build clean. SW freezmo-v23.

Needs a real device (documented in the README): iOS haptic tick,
system share sheet entry, wake-lock grant, iPhone startup images,
iOS edge-swipe back, install prompts. Pull-to-refresh and the app
badge were skipped on purpose.
This commit is contained in:
Martin Dvořák 2026-10-10 12:16:03 +00:00
commit 4369ba1a17
34 changed files with 1354 additions and 65 deletions

View file

@ -53,6 +53,15 @@ no database, no data leaving your machine except the requests you send to Ollama
*Edit* link back to its step. A flat Back/Cancel + Next/Freeze bar sits at the bottom above
the safe area (the tab bar hides inside the wizard), and it is portalled to `<body>` so the
screen entrance animation can never displace it. The desktop page is unchanged.
- **Native-feel touches (phones)**: the Android Back gesture closes the topmost layer first
(sheet, drawer, actions panel, then one wizard step); deleting a deck shows an *Undo*
toast instead of a confirm; the deck menu can share a deck as JSON through the system share
sheet (clipboard fallback); content shared from other apps opens a chooser and lands
pre-filled in either wizard; haptics (Settings → Haptics, on by default) tick on tab
switches, toggles, correct/wrong answers and completion; the screen stays awake during
study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping
the active tab scrolls to the top; an offline pill appears and generation is disabled with
a reason until the connection is back.
- **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV**
- Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered
as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out.
@ -505,12 +514,13 @@ What's included:
| Piece | Detail |
| --- | --- |
| `app/manifest.ts` | `display: standalone`, portrait, theme/background colours, app shortcuts |
| Icons | 192/512 px, a maskable 512 px icon, and 180/167/152 px `apple-touch-icon`s, all rendered from `assets/app-icon-source.jpeg` |
| iOS meta | `apple-mobile-web-app-capable`, `-title`, `-status-bar-style`, `viewport-fit=cover` |
| `app/manifest.ts` | `id`, `display: standalone`, portrait, theme/background colours, `any` + `maskable` + `monochrome` icons, three app shortcuts (New deck, Make my homework, Library) and a **share target** (text/URL via GET, files via POST) |
| Icons | 192/512 px, a maskable 512 px icon, a monochrome SVG and 180/167/152 px `apple-touch-icon`s, all rendered from `assets/app-icon-source.jpeg` |
| iOS meta | `apple-mobile-web-app-capable`, `-title`, `-status-bar-style`, `viewport-fit=cover`, plus **startup images** for the common iPhone sizes so launch has no white flash |
| Service worker | `public/sw.js`, **production only**: cached app shell, stale-while-revalidate for static assets, network-first navigations with an `/offline` fallback. `/api/*` is never cached |
| Offline page | `/offline` — "your decks are still here, reconnect to generate" |
| Safe areas | `pb-safe` / `pt-safe` utilities plus `pb-tabbar` so nothing hides behind the tab bar |
| Share target | `/share` chooser (New deck / Make my homework); POSTed files wait in a short-lived in-memory store, are handed to the wizard through `sessionStorage`, and are never written to disk |
The icons are generated by `scripts/make-icons.mjs` from `assets/app-icon-source.jpeg` (headless
Chromium); it is a one-off tool and is not part of the app's dependencies. It also writes
@ -884,10 +894,11 @@ Things I chose while building, with the reasoning:
device, uses Chrome's deferred `beforeinstallprompt` for a one-tap install where available, and
stays reachable from Settings. It is mounted in the root layout, so it can appear over any
screen — including the key gate — without blocking the page underneath.
30. **Phones don't zoom, and mobile fields are 16px.** The viewport sets `maximum-scale=1` /
`user-scalable=no` for an app-like feel, and a mobile-only rule keeps every input and textarea at
16px so iOS Safari's focus auto-zoom never fires (iOS ignores `user-scalable=no` for that).
Desktop keeps the designed 12–14px field sizes.
30. **Phones use touch-action, not a zoom lock.** The viewport allows pinch-zoom (accessibility),
and double-tap zoom is disabled by `touch-action: manipulation` on the UI chrome instead. A
mobile-only rule keeps every input and textarea at 16px so iOS Safari's focus auto-zoom never
fires (iOS ignores `user-scalable=no` for that anyway). Desktop keeps the designed 12–14px
field sizes.
31. **The app icon drives the palette.** The navy rounded-square artwork in
`assets/app-icon-source.jpeg` is the source of truth: `frost-*` is navy-tinted, the accent is
glacier cyan, the ice scene uses the artwork's blues, the primary CTA is an icy gradient, and
@ -970,6 +981,26 @@ Things I chose while building, with the reasoning:
position across the entrance (5 frames, 60–450ms) from the tab bar, the drawer and the
empty state, at 360/390, both themes, both languages and with reduced motion.
39. **Native feel is layered onto the platform, never around it.** The Back gesture closes the
topmost layer through a small history-entry stack (`lib/use-back-close.ts`): each layer pushes
a same-URL entry, only the top of the stack reacts, and a layer closed by a navigation leaves
its entry for the permanent listener to skip. Haptics are best-effort behind a Settings toggle
(Vibration API; the hidden `<input switch>` tick on iOS, throttled); the wake lock is requested
only while a study feed or guide is open and released on leave. Destructive-but-reversible
actions (delete deck, remove file) became Undo toasts; irreversible ones keep their confirm.
Wizard drafts and the last URL are per-browser; the share target hands POSTed files to the
client through a ten-minute in-memory store so nothing touches disk. Verified by the native
suite (35 checks: manifest/head/viewport, offline banner + disabled generate, share target GET
and POST, back-gesture closing for drawer/sheet/wizard, undo toast, share fallback, tab
re-tap, haptics persistence, draft restore) plus the six standing suites.
**Needs a real device to verify** (emulation can't do these honestly): the iOS hidden-switch
haptic tick, the Android/iOS **share target** entry in the system share sheet, the actual
**wake lock** grant, the **startup images** on an installed iPhone, the iOS **edge-swipe**
back gesture in standalone, and installability/rich-install prompts (Lighthouse covers the
manifest side). Pull-to-refresh and the app badge were skipped on purpose — the Settings
refresh icon already re-checks the connection, and there is no meaningful badge number.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision