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 (
+
{children}
+
diff --git a/app/page.tsx b/app/page.tsx
index e38e185..9a159be 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,5 @@
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
-import { InstallHint } from "@/components/InstallHint";
import { LibraryScreen } from "@/components/LibraryScreen";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar";
@@ -15,9 +14,6 @@ export default function HomePage() {
-
-
-
diff --git a/components/InstallHint.tsx b/components/InstallHint.tsx
deleted file mode 100644
index 4b9309d..0000000
--- a/components/InstallHint.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
-import { ShareIcon } from "@/components/icons";
-
-const DISMISS_KEY = "freezmo.installHintDismissed";
-
-/**
- * iOS has no `beforeinstallprompt`, so the only way to explain installing the
- * PWA is to point at the Share sheet. Shown once, only in iOS Safari, only
- * outside standalone mode.
- */
-export function InstallHint() {
- const [visible, setVisible] = useState(false);
-
- useEffect(() => {
- try {
- if (window.localStorage.getItem(DISMISS_KEY) === "1") return;
- const ua = window.navigator.userAgent;
- const isIos = /iPad|iPhone|iPod/.test(ua) || (ua.includes("Macintosh") && "ontouchend" in document);
- const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
- const standalone =
- window.matchMedia("(display-mode: standalone)").matches ||
- (window.navigator as unknown as { standalone?: boolean }).standalone === true;
- if (isIos && isSafari && !standalone) setVisible(true);
- } catch {
- /* private mode */
- }
- }, []);
-
- if (!visible) return null;
-
- const dismiss = () => {
- setVisible(false);
- try {
- window.localStorage.setItem(DISMISS_KEY, "1");
- } catch {
- /* ignore */
- }
- };
-
- return (
-
-
-
-
Install Freezmo
-
- Tap Share then{" "}
- Add to Home Screen for a full-screen, app-like
- experience that works without the browser chrome.
-
-
-
-
- );
-}
diff --git a/components/InstallSheet.tsx b/components/InstallSheet.tsx
new file mode 100644
index 0000000..2887f2c
--- /dev/null
+++ b/components/InstallSheet.tsx
@@ -0,0 +1,348 @@
+"use client";
+
+import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
+import { useCallback, useEffect, useState } from "react";
+
+import {
+ AddToHomeIcon,
+ ChromeIcon,
+ CompassIcon,
+ DownloadIcon,
+ EllipsisIcon,
+ MoreIcon,
+ PlusIcon,
+ ShareIcon,
+ SnowflakeIcon,
+ XIcon,
+} from "@/components/icons";
+import { Spinner } from "@/components/ui";
+import {
+ detectBrowser,
+ detectPlatform,
+ dismissInstallPrompt,
+ INSTALL_PROMPT_EVENT,
+ INSTALLED_EVENT,
+ installSheetTheme,
+ isDismissed,
+ isInstalled,
+ isStandalone,
+ markInstalled,
+ OPEN_INSTALL_EVENT,
+ type BeforeInstallPromptEvent,
+ type InstallBrowser,
+ type InstallPlatform,
+} from "@/lib/install";
+import { cn } from "@/lib/utils";
+
+type SheetInfo = {
+ platform: InstallPlatform;
+ browser: InstallBrowser;
+ theme: "dark" | "light";
+};
+
+/**
+ * "Install the app" bottom sheet. Mounted once in the root layout so it can
+ * appear over any screen (including the API-key gate) about a second after
+ * load. Phones/tablets only, never when already installed, and at most once
+ * per 7 days after a dismissal. Closing it leaves the site exactly as it was.
+ */
+export function InstallSheet() {
+ const [info, setInfo] = useState(null);
+ const [open, setOpen] = useState(false);
+ const [promptEvent, setPromptEvent] = useState(null);
+ const [installing, setInstalling] = useState(false);
+ const dragControls = useDragControls();
+ const reduceMotion = useReducedMotion();
+ const domain = typeof window === "undefined" ? "" : window.location.host;
+
+ /** Everything needed to render the sheet, or null if it shouldn't appear. */
+ const prepare = useCallback((): SheetInfo | null => {
+ const platform = detectPlatform();
+ if (platform === "other" || isInstalled()) return null;
+ return { platform, browser: detectBrowser(platform), theme: installSheetTheme() };
+ }, []);
+
+ // Automatic prompt: about a second after load, unless dismissed recently.
+ useEffect(() => {
+ if (isStandalone()) {
+ markInstalled();
+ return;
+ }
+ const next = prepare();
+ if (!next || isDismissed()) return;
+ const timer = window.setTimeout(() => {
+ setInfo(next);
+ setOpen(true);
+ }, 1000);
+ return () => window.clearTimeout(timer);
+ }, [prepare]);
+
+ // Manual open from the Settings row (ignores the 7-day window).
+ useEffect(() => {
+ const onOpen = () => {
+ const next = prepare();
+ if (!next) return;
+ setInfo(next);
+ setOpen(true);
+ };
+ window.addEventListener(OPEN_INSTALL_EVENT, onOpen);
+ return () => window.removeEventListener(OPEN_INSTALL_EVENT, onOpen);
+ }, [prepare]);
+
+ // Chrome's deferred install prompt + the "installed" signal.
+ useEffect(() => {
+ const sync = () => setPromptEvent(window.__freezmoInstallPrompt ?? null);
+ const onInstalled = () => {
+ markInstalled();
+ setPromptEvent(null);
+ setInstalling(false);
+ setOpen(false);
+ };
+ window.addEventListener(INSTALL_PROMPT_EVENT, sync);
+ window.addEventListener(INSTALLED_EVENT, onInstalled);
+ window.addEventListener("appinstalled", onInstalled);
+ sync();
+ return () => {
+ window.removeEventListener(INSTALL_PROMPT_EVENT, sync);
+ window.removeEventListener(INSTALLED_EVENT, onInstalled);
+ window.removeEventListener("appinstalled", onInstalled);
+ };
+ }, []);
+
+ const close = useCallback(() => {
+ dismissInstallPrompt();
+ setOpen(false);
+ setInstalling(false);
+ }, []);
+
+ // Escape closes, like every other sheet in the app.
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") close();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, close]);
+
+ const runPrompt = async () => {
+ if (!promptEvent || installing) return;
+ setInstalling(true);
+ try {
+ await promptEvent.prompt();
+ const choice = await promptEvent.userChoice;
+ if (choice.outcome === "accepted") {
+ markInstalled();
+ setOpen(false);
+ setInstalling(false);
+ return;
+ }
+ } catch {
+ /* the native prompt can fail; the steps below still work */
+ }
+ setInstalling(false);
+ };
+
+ const steps: React.ReactNode[] = [];
+ if (info?.platform === "ios") {
+ if (info.browser !== "safari") {
+ steps.push(
+ <>
+ Open this page in }>Safari.
+ >,
+ );
+ }
+ steps.push(
+ <>
+ Tap }>Share in the browser bar — in
+ some browsers it’s under }>⋯.
+ >,
+ );
+ steps.push(
+ <>
+ Tap }>More in the share sheet.
+ >,
+ );
+ steps.push(
+ <>
+ Choose }>Add to Home Screen.
+ >,
+ );
+ steps.push(
+ <>
+ Tap }>Add to confirm.
+ >,
+ );
+ } else if (info?.platform === "android") {
+ if (info.browser !== "chrome") {
+ steps.push(
+ <>
+ Open this page in }>Chrome.
+ >,
+ );
+ }
+ steps.push(
+ <>
+ Tap }>⋯ in the top right of the
+ browser.
+ >,
+ );
+ steps.push(
+ <>
+ Choose }>Install app or{" "}
+ }>Add to Home screen.
+ >,
+ );
+ steps.push(
+ <>
+ Tap }>Install to confirm.
+ >,
+ );
+ }
+
+ const showNativePrompt = info?.platform === "android" && Boolean(promptEvent);
+
+ return (
+ // The sheet is dark by default; only an explicit light choice in the app
+ // switches the palette (the wrapper scopes the app's .dark tokens).
+