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

@ -7,13 +7,17 @@ import { useState } from "react";
import { ApiError, generateDeck } from "@/lib/client-api";
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
import { createFolder as makeFolder, listFolders } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { shareDeck } from "@/lib/share";
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
import { showToast } from "@/lib/toast";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils";
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
type Busy = null | "more" | "regenerate";
@ -41,13 +45,15 @@ export function DeckCard({
const [menuOpen, setMenuOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [title, setTitle] = useState(deck.title);
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [addCount, setAddCount] = useState(5);
const [newFolder, setNewFolder] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
/* System Back closes the actions panel first. */
useBackClose(isPhone && menuOpen, () => setMenuOpen(false));
const hasSource = deck.sourceText.trim().length > 20;
const saveTitle = () => {
@ -230,7 +236,10 @@ export function DeckCard({
type="button"
aria-label={t("deck.actions")}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((value) => !value)}
onClick={() => {
haptic("tap");
setMenuOpen((value) => !value);
}}
className={cn(
"relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:-mt-1.5",
menuOpen && "bg-[var(--hover)] text-[var(--text)]",
@ -357,6 +366,18 @@ export function DeckCard({
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
{t("deck.export")}
</button>
<button
type="button"
className={menuRow}
onClick={() => {
setMenuOpen(false);
void shareDeck(deck);
}}
disabled={Boolean(busy)}
>
<ShareIcon className="size-4 text-[var(--text-subtle)]" />
{t("deck.share")}
</button>
<button
type="button"
className={menuRow}
@ -367,23 +388,25 @@ export function DeckCard({
{t("deck.regenerate")}
</button>
{confirmDelete ? (
<div className="flex items-center gap-2 px-3 py-2">
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
{t("deck.reallyDelete")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
{t("common.cancel")}
</Button>
</div>
) : (
<button type="button" className={cn(menuRow, "text-rose-600 dark:text-rose-300")} onClick={() => setConfirmDelete(true)}>
<span aria-hidden className="w-4 text-center">
⌫
</span>
{t("deck.delete")}
</button>
)}
{/* Deleting is reversible: no confirm, just an Undo toast. */}
<button
type="button"
className={cn(menuRow, "text-rose-600 dark:text-rose-300")}
onClick={() => {
setMenuOpen(false);
deleteDeck(deck.id);
showToast({
message: t("toast.deckDeleted"),
actionLabel: t("toast.undo"),
onAction: () => upsertDeck(deck),
});
}}
>
<span aria-hidden className="w-4 text-center">
⌫
</span>
{t("deck.delete")}
</button>
{/* folder ---------------------------------------------------- */}
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">