From 26e36bc0c0063799dd1c0723b3fcec3612f3cdd5 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Wed, 7 Oct 2026 08:27:43 +0000 Subject: [PATCH] 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. --- README.md | 31 ++- app/layout.tsx | 12 ++ app/page.tsx | 4 - components/InstallHint.tsx | 64 ------- components/InstallSheet.tsx | 348 ++++++++++++++++++++++++++++++++++ components/SettingsDialog.tsx | 34 +++- components/icons.tsx | 51 +++++ lib/install.ts | 165 ++++++++++++++++ 8 files changed, 631 insertions(+), 78 deletions(-) delete mode 100644 components/InstallHint.tsx create mode 100644 components/InstallSheet.tsx create mode 100644 lib/install.ts diff --git a/README.md b/README.md index 8e52065..600a308 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/layout.tsx b/app/layout.tsx index 6b9433e..970bb6c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; +import { InstallSheet } from "@/components/InstallSheet"; import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import "./globals.css"; @@ -51,15 +52,26 @@ export const viewport: Viewport = { /** Applies the saved theme before paint to avoid a flash. */ const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}})();`; +/** + * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates) + * and records an install so the prompt never comes back. Static, no user data. + */ +const installScript = `(function(){try{ +window.addEventListener('beforeinstallprompt',function(e){e.preventDefault();window.__freezmoInstallPrompt=e;window.dispatchEvent(new Event('freezmo:installprompt'));}); +window.addEventListener('appinstalled',function(){try{localStorage.setItem('freezmo.install.installed','1')}catch(e){}window.dispatchEvent(new Event('freezmo:installed'));}); +}catch(e){}})();`; + export default function RootLayout({ children }: { children: React.ReactNode }) { return (