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

63
lib/haptics.ts Normal file
View file

@ -0,0 +1,63 @@
"use client";
import { settingsStore } from "./settings";
/**
* Best-effort haptics, behind the Settings → Haptics toggle (on by default).
*
* Android/Chrome use the Vibration API. iOS ignores `navigator.vibrate`;
* since iOS 17.4 the system tick can be triggered by clicking a hidden
* `<input type="checkbox" switch>` label — we try that, throttled, and fail
* silently everywhere else. Nothing here ever throws or blocks input.
*/
export type HapticKind = "tap" | "toggle" | "success" | "error" | "celebrate";
const PATTERNS: Record<HapticKind, number | number[]> = {
tap: 8,
toggle: 12,
success: [14, 40, 18],
error: [30, 60, 30],
celebrate: [18, 50, 18, 50, 26],
};
let lastIosTick = 0;
function iosTick(): void {
const now = Date.now();
if (now - lastIosTick < 80) return;
lastIosTick = now;
try {
const label = document.createElement("label");
label.setAttribute("aria-hidden", "true");
label.style.position = "fixed";
label.style.left = "-9999px";
const input = document.createElement("input");
input.type = "checkbox";
input.setAttribute("switch", "");
label.appendChild(input);
document.body.appendChild(label);
input.click();
window.setTimeout(() => label.remove(), 0);
} catch {
/* no haptics available */
}
}
export function haptic(kind: HapticKind = "tap"): void {
if (typeof window === "undefined") return;
try {
if (settingsStore.getSnapshot().haptics === false) return;
} catch {
return;
}
try {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate(PATTERNS[kind]);
return;
}
iosTick();
} catch {
/* silent */
}
}

View file

@ -148,6 +148,26 @@ const en: Record<string, string> = {
"settings.details": "Details",
"settings.privacyLine": "Your key stays in this browser.",
"settings.recheck": "Re-check connection",
"settings.haptics": "Haptics",
"settings.hapticsNote": "Vibration feedback on supported phones.",
/* offline / share / toasts ---------------------------------------- */
"offline.banner": "You're offline — saved decks still work.",
"offline.generate": "You're offline — connect to generate.",
"share.title": "Add to Freezmo",
"share.intro": "What should I do with this?",
"share.preview": "Shared content",
"share.loading": "Loading the shared content…",
"share.expired": "That share expired — try again.",
"share.tooLarge": "That's too large to share (max 30 MB).",
"share.nothing": "Nothing to add.",
"share.copied": "Copied to the clipboard.",
"share.failed": "Sharing isn't available here.",
"toast.deckDeleted": "Deck deleted",
"toast.fileRemoved": "File removed",
"toast.undo": "Undo",
"upload.pasteClipboard": "Paste from clipboard",
"upload.clipboardDenied": "Clipboard access was denied.",
/* health ---------------------------------------------------------- */
"health.ok": "Ollama is reachable and both models are available.",
@ -300,6 +320,7 @@ const en: Record<string, string> = {
"deck.titleLabel": "Deck title",
"deck.rename": "Rename",
"deck.export": "Export JSON",
"deck.share": "Share",
"deck.regenerate": "Regenerate deck",
"deck.reallyDelete": "Really delete",
"deck.delete": "Delete deck",
@ -777,6 +798,26 @@ const cs: Record<string, string> = {
"settings.details": "Podrobnosti",
"settings.privacyLine": "Tvůj klíč zůstává v tomto prohlížeči.",
"settings.recheck": "Zkontrolovat připojení",
"settings.haptics": "Haptika",
"settings.hapticsNote": "Vibrace na podporovaných telefonech.",
/* offline / share / toasts ---------------------------------------- */
"offline.banner": "Jsi offline — uložené sady fungují dál.",
"offline.generate": "Jsi offline — pro vytvoření se připoj.",
"share.title": "Přidat do Freezma",
"share.intro": "Co s tím mám udělat?",
"share.preview": "Sdílený obsah",
"share.loading": "Načítám sdílený obsah…",
"share.expired": "Sdílení vypršelo — zkus to znovu.",
"share.tooLarge": "To je příliš velké (max 30 MB).",
"share.nothing": "Není co přidat.",
"share.copied": "Zkopírováno do schránky.",
"share.failed": "Sdílení tu není dostupné.",
"toast.deckDeleted": "Sada smazána",
"toast.fileRemoved": "Soubor odebrán",
"toast.undo": "Vrátit",
"upload.pasteClipboard": "Vložit ze schránky",
"upload.clipboardDenied": "Přístup ke schránce byl zamítnut.",
/* health ---------------------------------------------------------- */
"health.ok": "Ollama je dostupná a oba modely jsou k dispozici.",
@ -929,6 +970,7 @@ const cs: Record<string, string> = {
"deck.titleLabel": "Název sady",
"deck.rename": "Přejmenovat",
"deck.export": "Exportovat JSON",
"deck.share": "Sdílet",
"deck.regenerate": "Znovu vytvořit sadu",
"deck.reallyDelete": "Opravdu smazat",
"deck.delete": "Smazat sadu",

View file

@ -20,9 +20,11 @@ export type BrowserSettings = {
* and model names always come from the server's .env, never from the client.
*/
apiKey: string;
/** Best-effort haptics on supported devices (phones). On by default. */
haptics: boolean;
};
const EMPTY: BrowserSettings = { apiKey: "" };
const EMPTY: BrowserSettings = { apiKey: "", haptics: true };
let cached: BrowserSettings | null = null;
const listeners = new Set<() => void>();
@ -33,7 +35,10 @@ function read(): BrowserSettings {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<BrowserSettings>;
return { apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "" };
return {
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "",
haptics: parsed.haptics !== false,
};
} catch {
return EMPTY;
}
@ -42,8 +47,8 @@ function read(): BrowserSettings {
function write(next: BrowserSettings): void {
cached = next;
try {
if (next.apiKey) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey }));
if (next.apiKey || next.haptics === false) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics }));
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
@ -72,12 +77,13 @@ export function getApiKey(): string {
return settingsStore.getSnapshot().apiKey;
}
export function saveSettings(next: BrowserSettings): void {
write(next);
/** Merge-update: callers only pass what they changed. */
export function saveSettings(next: Partial<BrowserSettings>): void {
write({ ...settingsStore.getSnapshot(), ...next });
}
export function clearApiKey(): void {
write({ apiKey: "" });
write({ ...settingsStore.getSnapshot(), apiKey: "" });
}
/** Header used to hand the key to our own API routes for one request. */

43
lib/share-inbox.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Short-lived, single-use store for content shared into Freezmo from another
* app (the Web Share Target POST). Lives in memory only, expires after ten
* minutes and is deleted on first read — nothing is written to disk.
*/
export type SharedFile = { name: string; type: string; data: string };
export type SharedPayload = {
title?: string;
text?: string;
url?: string;
files: SharedFile[];
expires: number;
};
type Store = Map<string, SharedPayload>;
const globalStore = globalThis as typeof globalThis & { __freezmoShareStore?: Store };
const store: Store = (globalStore.__freezmoShareStore ??= new Map());
const TTL_MS = 10 * 60 * 1000;
export const MAX_SHARE_BYTES = 30 * 1024 * 1024;
function prune(): void {
const now = Date.now();
for (const [id, payload] of store) {
if (payload.expires < now) store.delete(id);
}
}
export function putShare(id: string, payload: Omit<SharedPayload, "expires">): void {
prune();
store.set(id, { ...payload, expires: Date.now() + TTL_MS });
}
export function takeShare(id: string): SharedPayload | null {
prune();
const payload = store.get(id);
if (!payload) return null;
store.delete(id);
return payload;
}

42
lib/share-pending.ts Normal file
View file

@ -0,0 +1,42 @@
"use client";
/**
* Content handed over by the share chooser: a small sessionStorage payload
* the wizards pick up on mount. Files are rebuilt from base64 into real File
* objects so the normal intake (validation, compression, parsing) runs.
*/
export type PendingShareFile = { name: string; type: string; data: string };
const KEY = "freezmo.share.pending.v1";
export function setPendingShare(payload: { text?: string; files?: PendingShareFile[] }): void {
try {
window.sessionStorage.setItem(KEY, JSON.stringify(payload));
} catch {
/* private mode */
}
}
function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
return bytes;
}
/** Reads and clears the pending share; returns real File objects. */
export function takePendingShare(): { text: string; files: File[] } | null {
try {
const raw = window.sessionStorage.getItem(KEY);
if (!raw) return null;
window.sessionStorage.removeItem(KEY);
const parsed = JSON.parse(raw) as { text?: string; files?: PendingShareFile[] };
const files = (parsed.files ?? []).map(
(file) => new File([base64ToBytes(file.data) as unknown as BlobPart], file.name, { type: file.type }),
);
return { text: parsed.text ?? "", files };
} catch {
return null;
}
}

72
lib/share.ts Normal file
View file

@ -0,0 +1,72 @@
"use client";
import { exportDeck } from "./storage";
import { showToast } from "./toast";
import type { Deck, HomeworkRecord } from "./schemas";
import { t } from "./i18n";
/**
* Web Share with graceful fallbacks: file share (deck JSON) where the browser
* supports it, text share otherwise, clipboard copy as the last resort.
*/
type ShareNavigator = Navigator & {
canShare?: (data: { files?: File[]; text?: string; title?: string }) => boolean;
share?: (data: { files?: File[]; text?: string; title?: string }) => Promise<void>;
};
function deckFile(deck: Deck): File {
return new File([exportDeck(deck)], `${deck.title.replace(/[^\p{L}\p{N} _-]/gu, "").trim() || "deck"}.json`, {
type: "application/json",
});
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through */
}
return false;
}
export async function shareDeck(deck: Deck): Promise<void> {
const nav = navigator as ShareNavigator;
const file = deckFile(deck);
try {
if (nav.share && nav.canShare?.({ files: [file] })) {
await nav.share({ files: [file], title: deck.title });
return;
}
if (nav.share) {
await nav.share({ title: deck.title, text: deck.title });
return;
}
} catch (error) {
// The user cancelling the share sheet is not an error.
if (error instanceof DOMException && error.name === "AbortError") return;
}
if (await copyText(exportDeck(deck))) showToast({ message: t("share.copied") });
else showToast({ message: t("share.failed") });
}
export async function shareHomework(record: HomeworkRecord): Promise<void> {
const nav = navigator as ShareNavigator;
const text =
record.mode === "make"
? record.answer
: record.steps.map((step, index) => `${index + 1}. ${step.title}`).join("\n");
try {
if (nav.share) {
await nav.share({ title: record.title, text });
return;
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
}
if (await copyText(text)) showToast({ message: t("share.copied") });
else showToast({ message: t("share.failed") });
}

31
lib/toast.ts Normal file
View file

@ -0,0 +1,31 @@
"use client";
/**
* Tiny toast bus: destructive-but-reversible actions (delete deck, remove a
* file) show an Undo toast instead of a blocking confirm.
*/
export type ToastInput = {
message: string;
actionLabel?: string;
onAction?: () => void;
durationMs?: number;
};
export type Toast = ToastInput & { id: number };
const listeners = new Set<(toast: Toast) => void>();
let nextId = 1;
export function showToast(input: ToastInput): number {
const toast: Toast = { durationMs: 5000, ...input, id: nextId++ };
listeners.forEach((listener) => listener(toast));
return toast.id;
}
export const toastBus = {
subscribe(listener: (toast: Toast) => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
};

109
lib/use-back-close.ts Normal file
View file

@ -0,0 +1,109 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
/**
* Lets the system Back gesture/button close the topmost layer first.
*
* While `open` is true a same-URL history entry is pushed; a popstate (the
* Android back gesture, the browser back button, an iOS edge swipe in
* standalone) closes the layer instead of leaving the page. Layers form a
* stack, so only the topmost one reacts. Closing a layer through its own UI
* pops the pushed entry again — unless the layer closed because of a
* navigation (a drawer link), in which case the entry is left for the
* permanent listener to skip on the next Back press.
*/
type State = { freezmoLayer?: boolean };
const stack: symbol[] = [];
export function useBackClose(open: boolean, onClose: () => void) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
const openRef = useRef(open);
openRef.current = open;
const idRef = useRef<symbol>(Symbol("layer"));
const pushedRef = useRef(false);
const closingRef = useRef(false);
const skipRef = useRef(false);
const poppedRef = useRef(false);
const urlRef = useRef("");
/** Call before closing for a navigation: the entry is left for the skipper. */
const skipRewind = useCallback(() => {
skipRef.current = true;
}, []);
// Push the entry when the layer opens; rewind it when it closes on its own.
useEffect(() => {
const id = idRef.current;
if (open) {
if (pushedRef.current) return;
pushedRef.current = true;
urlRef.current = window.location.href;
stack.push(id);
try {
window.history.pushState({ freezmoLayer: true }, "");
} catch {
pushedRef.current = false;
stack.splice(stack.indexOf(id), 1);
}
return;
}
if (!pushedRef.current) return;
pushedRef.current = false;
const index = stack.indexOf(id);
if (index >= 0) stack.splice(index, 1);
if (skipRef.current) {
// Closed because of a navigation: leave the entry alone, the permanent
// listener skips it on the next Back press.
skipRef.current = false;
return;
}
if (poppedRef.current) {
// The Back press already consumed the entry — nothing to rewind.
poppedRef.current = false;
return;
}
if (window.location.href === urlRef.current) {
closingRef.current = true;
try {
window.history.back();
} catch {
/* ignore */
}
}
}, [open]);
// One listener per mounted component: close this layer if it is the top of
// the stack; otherwise skip stale entries left behind by navigations.
useEffect(() => {
const id = idRef.current;
const onPop = () => {
if (closingRef.current) {
closingRef.current = false;
return;
}
if (openRef.current) {
if (stack[stack.length - 1] !== id) return;
poppedRef.current = true;
closeRef.current();
return;
}
const state = window.history.state as State | null;
if (state?.freezmoLayer) {
try {
window.history.back();
} catch {
/* ignore */
}
}
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return { skipRewind };
}

21
lib/use-online.ts Normal file
View file

@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
/** True while the browser reports a connection. Starts optimistic (SSR). */
export function useOnline(): boolean {
const [online, setOnline] = useState(true);
useEffect(() => {
const sync = () => setOnline(navigator.onLine);
sync();
window.addEventListener("online", sync);
window.addEventListener("offline", sync);
return () => {
window.removeEventListener("online", sync);
window.removeEventListener("offline", sync);
};
}, []);
return online;
}

51
lib/use-wake-lock.ts Normal file
View file

@ -0,0 +1,51 @@
"use client";
import { useEffect } from "react";
type WakeLockSentinelLike = {
release: () => Promise<void>;
addEventListener: (type: string, listener: () => void) => void;
};
type NavigatorWithWakeLock = Navigator & {
wakeLock?: { request: (type: "screen") => Promise<WakeLockSentinelLike> };
};
/**
* Keeps the screen awake while a study session or guide is open
* (feature-detected; silently does nothing where unsupported or denied).
* Re-acquires when the tab becomes visible again, releases on leave.
*/
export function useWakeLock(active = true): void {
useEffect(() => {
if (!active || typeof navigator === "undefined") return;
const wakeLock = (navigator as NavigatorWithWakeLock).wakeLock;
if (!wakeLock) return;
let sentinel: WakeLockSentinelLike | null = null;
let disposed = false;
const acquire = async () => {
try {
sentinel = await wakeLock.request("screen");
sentinel.addEventListener("release", () => {
sentinel = null;
});
} catch {
/* denied or not allowed without a gesture — fine */
}
};
const onVisibility = () => {
if (document.visibilityState === "visible" && !sentinel && !disposed) void acquire();
};
void acquire();
document.addEventListener("visibilitychange", onVisibility);
return () => {
disposed = true;
document.removeEventListener("visibilitychange", onVisibility);
void sentinel?.release().catch(() => {});
};
}, [active]);
}

66
lib/wizard-draft.ts Normal file
View file

@ -0,0 +1,66 @@
"use client";
/**
* Draft persistence for the wizards, so an OS kill (or an accidental reload)
* doesn't lose the user's photos, pasted text or options. Best-effort: photo
* data URLs can be large, so every write is guarded and skipped on quota
* errors instead of breaking the flow.
*/
export type WizardDraft = {
v: 1;
savedAt: number;
text?: string;
focus?: string;
difficulty?: string;
count?: number;
folderId?: string;
images?: Array<{ id: string; name: string; dataUrl: string; bytes: number; width: number; height: number }>;
};
const TTL_MS = 24 * 60 * 60 * 1000;
const MAX_BYTES = 3_000_000;
function keyFor(scope: "deck" | "homework"): string {
return `freezmo.draft.${scope}.v1`;
}
export function saveWizardDraft(scope: "deck" | "homework", draft: Omit<WizardDraft, "v" | "savedAt">): void {
try {
const images = (draft.images ?? []).filter(() => true);
const size = images.reduce((sum, image) => sum + image.dataUrl.length, 0);
const payload: WizardDraft = {
v: 1,
savedAt: Date.now(),
...draft,
// Photos over the budget are dropped rather than failing the whole save.
images: size > MAX_BYTES ? [] : images,
};
window.localStorage.setItem(keyFor(scope), JSON.stringify(payload));
} catch {
/* quota or private mode — drafts are a nice-to-have */
}
}
export function loadWizardDraft(scope: "deck" | "homework"): WizardDraft | null {
try {
const raw = window.localStorage.getItem(keyFor(scope));
if (!raw) return null;
const parsed = JSON.parse(raw) as WizardDraft;
if (parsed.v !== 1 || Date.now() - parsed.savedAt > TTL_MS) {
window.localStorage.removeItem(keyFor(scope));
return null;
}
return parsed;
} catch {
return null;
}
}
export function clearWizardDraft(scope: "deck" | "homework"): void {
try {
window.localStorage.removeItem(keyFor(scope));
} catch {
/* ignore */
}
}