Add the Install the app bottom sheet for mobile browsers

- New InstallSheet mounted in the root layout: appears ~1s after load on
  iOS/Android phones and tablets, over any screen including the key gate,
  with a dimmed backdrop. Never on desktop, never in standalone mode, and
  at most once per 7 days after a dismissal (localStorage).
- Platform/browser detection (lib/install.ts): iPhone/iPad incl. iPadOS
  reporting as Mac with touch, Android, Safari vs Chrome vs other vs
  in-app browsers, with step 1 adapted accordingly.
- iOS: numbered steps with chips (Safari, Share, ... , More, Add to Home
  Screen) and the accent Add pill. Android: captured beforeinstallprompt
  turns into a native Install button; fallback steps use Install app /
  Add to Home screen chips and an accent Install pill.
- Close via X, backdrop, Escape or swipe-down on the handle; the site is
  untouched afterwards. Anchored to 100dvh so browser toolbars never hide
  the last step; pb-safe respects the home indicator.
- Dark sheet by default; light only when the app theme was explicitly set
  to light. Settings gains a mobile-only Install app row that reopens it.
- The inline head script captures beforeinstallprompt before hydration and
  records appinstalled. Old InstallHint banner removed.
- Tests: new install-sheet suite (45 checks across iPhone Safari/Chrome/
  in-app, Android Chrome/Samsung, desktop, prompt path, dismissal, 7-day
  expiry, standalone, Settings row, both themes); pwa suite updated.
This commit is contained in:
Martin Dvořák 2026-10-07 08:27:43 +00:00
commit 26e36bc0c0
8 changed files with 632 additions and 79 deletions

View file

@ -395,13 +395,18 @@ Decks are organised into folders — the same way you'd file paper notes.
Freezmo ships as an installable Progressive Web App with iOS in mind.
**iOS / iPadOS (Safari):** open the app → **Share** → **Add to Home Screen**. The app shows a
one-time hint explaining exactly this. It launches full-screen in standalone mode (no browser
chrome), respects the notch/home-indicator safe areas, and has an iOS-style bottom tab bar
(Library · New deck · Settings) with sheet-style dialogs.
Phones and tablets get an **"Install the app" bottom sheet** about a second after load (never on
desktop, never when already installed, and at most once per 7 days after a dismissal). It is a
native-feeling sheet with the Freezmo card, numbered steps and chips for the exact buttons to tap,
plus an accent confirmation pill. Close it with the X, the backdrop, Escape, or a swipe down — the
site keeps working normally. On iOS the steps adapt to the browser (Safari, Chrome, in-app), and
on Android Chrome a captured `beforeinstallprompt` turns into a real **Install** button. The sheet
can be reopened any time from **Settings → Install app** (mobile only).
**Android (Chrome):** Chrome offers "Install app" from the ⋮ menu; Freezmo also works offline for
the parts that don't need a model.
It launches full-screen in standalone mode (no browser chrome), respects the notch/home-indicator
safe areas, and has an iOS-style bottom tab bar (Library · New deck · Settings) with sheet-style
dialogs. Android Chrome also offers "Install app" from the ⋮ menu; Freezmo works offline for the
parts that don't need a model.
What's included:
@ -430,8 +435,9 @@ self-hosted study app with no accounts, no database and no server-side state.
production `.next` output for a test key, and by restarting the server (which remembers nothing).
- **No server-side injection surface.** The Ollama endpoint and model names come only from the
server's `.env`; the client cannot point the server at another host. Model output is validated with
zod and rendered as text — there is no `dangerouslySetInnerHTML` except one static theme script,
so a deck cannot inject markup or script (React escapes everything).
zod and rendered as text — there is no `dangerouslySetInnerHTML` except two static inline scripts
(the pre-paint theme choice and the `beforeinstallprompt` capture), so a deck cannot inject markup
or script (React escapes everything).
- **Key-header sanitisation.** Keys from the client are stripped of control characters/whitespace
and capped at 512 chars before they are used in an outbound header, so a crafted key cannot inject
headers. Verified with a raw-socket CRLF attempt.
@ -540,7 +546,7 @@ components/
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
TabBar.tsx InstallHint.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
lib/
ollama.ts server-only HTTP client (timeouts, auth, error mapping)
@ -550,6 +556,7 @@ lib/
config.ts env config, cloud/direct model-name normalisation
documents.ts client-side PDF/DOCX/PPTX/ODT/text extraction
folders.ts folder library (create/rename/delete) in localStorage
install.ts install-prompt platform/browser detection + 7-day dismissal
settings.ts browser-side settings (the user's API key) + auth headers
use-health.ts client hook for /api/health, re-runs when the key changes
rate-limit.ts in-memory sliding-window limiter for the API routes
@ -760,6 +767,12 @@ Things I chose while building, with the reasoning:
`/api/health`, so an invalid key and an unreachable Ollama are worded differently. The old
dashboard banner is gone; key management stays in Settings, and removing the key brings the
gate back. The gate covers `/`, `/create` and deck study, but never `/offline`.
29. **The install prompt is a teaching sheet, not a nag.** It appears once, phones/tablets only,
a second after load, and never in standalone mode; dismissing it silences it for 7 days. It
detects iOS vs Android and Safari vs Chrome vs an in-app browser so step 1 always matches the
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.
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