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

@ -27,12 +27,18 @@ import {
type PreparedImage,
} from "@/lib/image";
import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { stepVariants } from "@/lib/motion";
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { takePendingShare } from "@/lib/share-pending";
import { upsertDeck } from "@/lib/storage";
import { showToast } from "@/lib/toast";
import { openSettings } from "@/lib/ui-events";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone";
import { useOnline } from "@/lib/use-online";
import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft";
import { cn, uid } from "@/lib/utils";
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill";
@ -112,6 +118,20 @@ export function UploadScreen() {
const [pasteOpen, setPasteOpen] = useState(false);
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
const online = useOnline();
const [clipboardAvailable, setClipboardAvailable] = useState(false);
/* System Back goes one wizard step back (phones), then leaves the page. */
useBackClose(isPhone && step > 1, () => {
setStepDir(-1);
setStep((value) => Math.max(1, value - 1));
});
useEffect(() => {
setClipboardAvailable(
typeof navigator !== "undefined" && typeof navigator.clipboard?.readText === "function",
);
}, []);
const folders = useSyncExternalStore(
folderStore.subscribe,
@ -264,6 +284,37 @@ export function UploadScreen() {
const imagesSkipped =
images.length + documentImages.length - imagesToSend.length;
/* Restore a draft after an OS kill / reload; clear it on success. */
useEffect(() => {
const draft = loadWizardDraft("deck");
if (!draft) return;
if (draft.text) setText(draft.text);
if (draft.focus) setFocus(draft.focus);
if (draft.difficulty === "easy" || draft.difficulty === "medium" || draft.difficulty === "hard") {
setDifficulty(draft.difficulty);
}
if (draft.count) setCount(draft.count);
if (draft.folderId) setFolderId(draft.folderId);
if (draft.images?.length) setImages(draft.images);
}, []);
/* Content shared in from another app (share target) lands here. */
useEffect(() => {
const pending = takePendingShare();
if (!pending) return;
if (pending.text) setText((current) => (current ? `${current}\n${pending.text}` : pending.text));
if (pending.files.length > 0) void addFiles(pending.files);
}, [addFiles]);
/* Persist the draft while the user works (debounced, best-effort). */
useEffect(() => {
if (phase === "loading") return;
const timer = window.setTimeout(() => {
saveWizardDraft("deck", { text, focus, difficulty, count, folderId, images });
}, 400);
return () => window.clearTimeout(timer);
}, [text, focus, difficulty, count, folderId, images, phase]);
const generate = async () => {
if (!canGenerate) return;
setError(null);
@ -300,6 +351,7 @@ export function UploadScreen() {
attempts: 0,
};
upsertDeck(stored);
clearWizardDraft("deck");
router.push(`/deck/${stored.id}`);
} catch (generateError) {
setError({
@ -330,7 +382,10 @@ export function UploadScreen() {
key={folder.id}
type="button"
aria-pressed={active}
onClick={() => setFolderId(folder.id)}
onClick={() => {
haptic("toggle");
setFolderId(folder.id);
}}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition",
active
@ -369,6 +424,10 @@ export function UploadScreen() {
aria-label={t("common.newFolderName")}
placeholder={t("upload.folderPlaceholder")}
maxLength={60}
enterKeyHint="done"
autoCapitalize="words"
autoComplete="off"
spellCheck={false}
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
/>
<Button
@ -410,7 +469,10 @@ export function UploadScreen() {
type="button"
role="radio"
aria-checked={selected}
onClick={() => setDifficulty(option.value)}
onClick={() => {
haptic("toggle");
setDifficulty(option.value);
}}
className={cn(
"relative flex flex-col items-center gap-1 overflow-hidden rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
selected && isPhone
@ -448,7 +510,10 @@ export function UploadScreen() {
key={option}
type="button"
aria-pressed={count === option}
onClick={() => setCount(option)}
onClick={() => {
haptic("toggle");
setCount(option);
}}
className={cn(
"relative min-w-12 overflow-hidden rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
count === option && isPhone
@ -493,11 +558,13 @@ export function UploadScreen() {
</div>
) : null;
const phoneHelper = documentsBusy
? t("upload.readingDocs")
: hasContent
? t("upload.ready")
: t("upload.addToStart");
const phoneHelper = !online
? t("offline.generate")
: documentsBusy
? t("upload.readingDocs")
: hasContent
? t("upload.ready")
: t("upload.addToStart");
/* Phones: the 3-step flow. Same state as the desktop card, different shape. */
if (isPhone) {
@ -622,13 +689,50 @@ export function UploadScreen() {
{t("upload.pasteInstead")}
</Button>
</div>
{clipboardAvailable ? (
<Button
variant="ghost"
size="lg"
className="touch-target w-full"
onClick={() => {
void (async () => {
try {
const clip = await navigator.clipboard.readText();
if (clip) {
setText((current) => (current ? `${current}\n${clip}` : clip));
setPasteOpen(true);
}
haptic("tap");
} catch {
setFileError(t("upload.clipboardDenied"));
}
})();
}}
>
{t("upload.pasteClipboard")}
</Button>
) : null}
{pasteOpen || text.trim().length > 0 ? (
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (hasContent && !documentsBusy) {
setStepDir(1);
setStep(2);
}
}
}}
rows={5}
placeholder={t("upload.pastedPlaceholder")}
aria-label={t("upload.pastedNotes")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
) : null}
@ -645,7 +749,20 @@ export function UploadScreen() {
/>
<button
type="button"
onClick={() => removeImage(image.id)}
onClick={() => {
const removed = images.find((entry) => entry.id === image.id);
removeImage(image.id);
if (removed) {
showToast({
message: t("toast.fileRemoved"),
actionLabel: t("toast.undo"),
onAction: () =>
setImages((current) =>
current.some((entry) => entry.id === removed.id) ? current : [...current, removed],
),
});
}
}}
aria-label={t("upload.remove", { name: image.name })}
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
>
@ -676,7 +793,20 @@ export function UploadScreen() {
) : null}
<button
type="button"
onClick={() => removeDocument(entry.id)}
onClick={() => {
const snapshot = documents.find((doc) => doc.id === entry.id);
removeDocument(entry.id);
if (snapshot) {
showToast({
message: t("toast.fileRemoved"),
actionLabel: t("toast.undo"),
onAction: () =>
setDocuments((current) =>
current.some((doc) => doc.id === snapshot.id) ? current : [...current, snapshot],
),
});
}
}}
aria-label={t("upload.remove", { name: entry.name })}
className="grid size-6 place-items-center rounded-full text-[11px] font-bold text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
@ -764,6 +894,11 @@ export function UploadScreen() {
value={focus}
onChange={(event) => setFocus(event.target.value)}
placeholder={t("upload.topicPlaceholder")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck={false}
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
</div>
@ -874,7 +1009,7 @@ export function UploadScreen() {
{t("common.next")}
</Button>
) : (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate}>
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate || !online}>
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
</Button>
)}