diff --git a/src/App.module.css b/src/App.module.css index 38a9dcf..d68b444 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -99,3 +99,16 @@ color: var(--on-accent); font-weight: 700; } + +/* Group headings inside a sheet: Deck, Direction, Answering. */ +.sheetLabel { + margin: 8px 0 2px; + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.12em; + color: var(--muted); +} + +.sheetLabel:first-child { + margin-top: 0; +} diff --git a/src/App.tsx b/src/App.tsx index 8c01409..8f0b4c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,10 +3,13 @@ * what is lit, what the screen says, and what fills the strip and the card box. * * Angular: useReducer is the closest thing to a service holding state with - * methods, except dispatch returns a new state rather than mutating one. + * methods, except dispatch returns a new state rather than mutating one. Its + * third argument is a lazy initialiser, which is how the stored blob is read + * once at startup instead of on every render. */ import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill"; +import type { DeckId, Direction, InputMode } from "./domain/drill"; import { ENTRIES } from "./domain/entries"; import { controlName } from "./domain/format"; import { CTX, CTXHINT } from "./domain/labels"; @@ -21,11 +24,14 @@ import { DrillCard } from "./ui/DrillCard"; import { DrillStrip } from "./ui/DrillStrip"; import { InfoCard } from "./ui/InfoCard"; import { Panel } from "./ui/Panel"; +import { Sheet, SheetOption } from "./ui/Sheet"; import { drillReducer, initialDrillState } from "./ui/drillReducer"; +import { loadState, saveState } from "./ui/persistedState"; import styles from "./App.module.css"; const TABS = ["Explore", "Drill", "Find", "Rules"] as const; type Tab = (typeof TABS)[number]; +type OpenSheet = null | "mode" | "layer" | "settings"; function Caret() { return ( @@ -38,6 +44,8 @@ function Caret() { const layerLabel = (key: string): string => key === "" ? "NO MODIFIER" : key.split("+").map(controlName).join(" + "); +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + /** Three other names to choose between, preferring ones from the same mode. */ function optionsFor(card: Entry): string[] { const others = ENTRIES.filter((e) => e.name !== card.name); @@ -51,11 +59,15 @@ function optionsFor(card: Entry): string[] { return [...names, card.name].sort(() => Math.random() - 0.5); } +/** Read once, at startup. Anything unrecognised falls back to a default. */ +const stored = loadState(); + export function App() { const [tab, setTab] = useState("Explore"); + const [sheet, setSheet] = useState(null); // --- Explore --- - const [ctx, setCtx] = useState("S"); + const [ctx, setCtx] = useState(stored.ctx); const [layer, setLayer] = useState(""); const [selected, setSelected] = useState(null); @@ -69,9 +81,8 @@ export function App() { const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]); // --- Drill --- - const [drill, dispatch] = useReducer( - drillReducer, - initialDrillState("shiftpads", "play", "tap", {}), + const [drill, dispatch] = useReducer(drillReducer, stored, (s) => + initialDrillState(s.deck, s.dir, s.input, s.boxes), ); const pool = useMemo( () => deckPool(ENTRIES, drill.deck, drill.direction), @@ -87,16 +98,26 @@ export function App() { }); }, [pool, drill.boxes, drill.card, drill.direction]); - // Deal the first card when Drill is opened. This has to be an effect, not a - // call during render: dealing picks at random, and StrictMode renders twice. + // Dealing picks at random, and StrictMode renders twice, so this cannot + // happen during render. useEffect(() => { if (tab === "Drill" && drill.card === null && pool.length > 0) deal(); }, [tab, drill.card, pool, deal]); + // Everything worth remembering, written whenever it changes. + useEffect(() => { + saveState({ + boxes: drill.boxes, + ctx, + deck: drill.deck, + dir: drill.direction, + input: drill.input, + }); + }, [drill.boxes, drill.deck, drill.direction, drill.input, ctx]); + const drillLit = useMemo(() => { - if (!drill.card) return new Map(); - if (drill.answered) return entriesByTarget([drill.card]); - return new Map(); + if (!drill.card || !drill.answered) return new Map(); + return entriesByTarget([drill.card]); }, [drill.card, drill.answered]); const isDrill = tab === "Drill"; @@ -109,29 +130,15 @@ export function App() { known={knownCount(pool, drill.boxes)} total={pool.length} distribution={boxDistribution(pool, drill.boxes)} - onSettings={() => undefined} + onSettings={() => setSheet("settings")} /> ) : (
- - @@ -167,7 +174,7 @@ export function App() { : { top: CTX[ctx].toUpperCase(), right: layerLabel(activeLayer), - main: selected ? selected.short : `${onLayer.length} functions`, + main: selected ? selected.short : plural(onLayer.length, "function"), chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short), } } @@ -197,6 +204,89 @@ export function App() { ))} + + setSheet(null)}> + {(Object.keys(CTX) as ContextCode[]).map((code) => ( + { + setCtx(code); + setSelected(null); + setSheet(null); + }} + /> + ))} + + + setSheet(null)} + > + {layers.map((key) => ( + { + setLayer(key); + setSelected(null); + setSheet(null); + }} + /> + ))} + + + setSheet(null)} + > +

Deck

+ {(Object.keys(DECKS) as DeckId[]).map((id) => ( + dispatch({ type: "setDeck", deck: id })} + /> + ))} + +

Direction

+ {( + [ + ["play", "Function → press it"], + ["name", "Combo → name it"], + ] as [Direction, string][] + ).map(([id, label]) => ( + dispatch({ type: "setDirection", direction: id })} + /> + ))} + +

Answering

+ {( + [ + ["hold", "Hold the combo together"], + ["tap", "Tap each, then check"], + ] as [InputMode, string][] + ).map(([id, label]) => ( + dispatch({ type: "setInput", input: id })} + /> + ))} +
); } diff --git a/src/ui/Sheet.module.css b/src/ui/Sheet.module.css new file mode 100644 index 0000000..2f99a35 --- /dev/null +++ b/src/ui/Sheet.module.css @@ -0,0 +1,92 @@ +.scrim { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 20; +} + +.sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 21; + max-height: 80dvh; + display: flex; + flex-direction: column; + background: var(--card); + border-top: var(--outline); + /* The hard shadow points up here, since the sheet rises from the bottom. */ + box-shadow: 0 -4px 0 var(--ink); +} + +.head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--rule); +} + +.title { + font-size: 12px; + letter-spacing: 0.08em; +} + +.close { + flex: 0 0 auto; + width: 32px; + height: 32px; + display: grid; + place-items: center; + background: none; + border: var(--outline); + color: var(--ink); +} + +.body { + overflow-y: auto; + padding: 10px 12px 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.option, +.optionOn { + display: block; + width: 100%; + min-height: 44px; + text-align: left; + padding: 7px 10px; + border: var(--outline); + background: var(--page); + color: var(--ink); +} + +.optionOn { + background: var(--accent); + color: var(--on-accent); +} + +.optionLabel { + display: block; + font-size: 13px; + font-weight: 700; + line-height: 1.15; +} + +.optionHint { + display: block; + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.06em; + color: var(--muted); +} + +.optionOn .optionHint { + color: var(--on-accent); + opacity: 0.75; +} diff --git a/src/ui/Sheet.tsx b/src/ui/Sheet.tsx new file mode 100644 index 0000000..fc0c646 --- /dev/null +++ b/src/ui/Sheet.tsx @@ -0,0 +1,72 @@ +/** + * Bottom sheet. Used by the drill settings and by Explore's two pickers. + * + * Angular: roughly a MatBottomSheet, except there is no service and no portal — + * it is just markup this component renders when open. + */ +import { useEffect } from "react"; +import type { ReactNode } from "react"; +import styles from "./Sheet.module.css"; + +export interface SheetProps { + readonly open: boolean; + readonly title: string; + readonly onClose: () => void; + readonly children: ReactNode; +} + +export function Sheet({ open, title, onClose, children }: SheetProps) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( + <> +
+
+
+ {title.toUpperCase()} + +
+
{children}
+
+ + ); +} + +export interface SheetOptionProps { + readonly label: string; + readonly hint?: string; + readonly selected: boolean; + readonly onSelect: () => void; +} + +/** One choice in a sheet: a full-width row, 44px minimum. */ +export function SheetOption({ label, hint, selected, onSelect }: SheetOptionProps) { + return ( + + ); +} diff --git a/src/ui/persistedState.test.ts b/src/ui/persistedState.test.ts new file mode 100644 index 0000000..97c3570 --- /dev/null +++ b/src/ui/persistedState.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULTS, STORAGE_KEY } from "../domain/storage"; +import { loadState, saveState } from "./persistedState"; + +/** Tests run in node, which has no localStorage, so one is installed per case. */ +function useStorage(impl: Partial) { + Object.defineProperty(globalThis, "localStorage", { + value: impl as Storage, + configurable: true, + writable: true, + }); +} + +function memoryStorage(seed: Record = {}) { + const map = new Map(Object.entries(seed)); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + read: (k: string) => map.get(k) ?? null, + }; +} + +afterEach(() => { + Reflect.deleteProperty(globalThis, "localStorage"); +}); + +describe("loadState", () => { + it("returns defaults when nothing has been stored", () => { + useStorage(memoryStorage()); + expect(loadState()).toEqual(DEFAULTS); + }); + + it("reads the boxes already on the phone", () => { + // Copied verbatim out of CapacitorStorage.xml on the device during Phase 1. + // If this ever fails, the React app has orphaned real drill progress. + useStorage( + memoryStorage({ + [STORAGE_KEY]: '{"boxes":{"shift>p6@S#INIT":1,"shift>p8@S#MUTE GRP":2}}', + }), + ); + const state = loadState(); + expect(state.boxes["shift>p6@S#INIT"]).toBe(1); + expect(state.boxes["shift>p8@S#MUTE GRP"]).toBe(2); + }); + + it("keeps the rest of the blob", () => { + useStorage( + memoryStorage({ + [STORAGE_KEY]: '{"boxes":{},"ctx":"P","deck":"dj","dir":"name","input":"tap"}', + }), + ); + expect(loadState()).toEqual({ + boxes: {}, + ctx: "P", + deck: "dj", + dir: "name", + input: "tap", + }); + }); + + it("falls back to defaults on damaged JSON", () => { + useStorage(memoryStorage({ [STORAGE_KEY]: "{not json" })); + expect(loadState()).toEqual(DEFAULTS); + }); + + it("survives storage that throws, as a private window does", () => { + useStorage({ + getItem: () => { + throw new DOMException("denied", "SecurityError"); + }, + }); + expect(loadState()).toEqual(DEFAULTS); + }); + + it("survives localStorage being absent entirely", () => { + Reflect.deleteProperty(globalThis, "localStorage"); + expect(loadState()).toEqual(DEFAULTS); + }); +}); + +describe("saveState", () => { + it("writes under the key Phase 1 already uses", () => { + const store = memoryStorage(); + useStorage(store); + saveState({ ...DEFAULTS, boxes: { "shift>p1@S#FIX VEL": 3 } }); + expect(JSON.parse(store.read(STORAGE_KEY)!).boxes["shift>p1@S#FIX VEL"]).toBe(3); + }); + + it("round-trips through loadState", () => { + useStorage(memoryStorage()); + const state = { ...DEFAULTS, ctx: "D" as const, boxes: { a: 4 } }; + saveState(state); + expect(loadState()).toEqual(state); + }); + + it("does not throw when the quota is refused", () => { + useStorage({ + setItem: () => { + throw new DOMException("full", "QuotaExceededError"); + }, + }); + expect(() => saveState(DEFAULTS)).not.toThrow(); + }); +}); diff --git a/src/ui/persistedState.ts b/src/ui/persistedState.ts new file mode 100644 index 0000000..2913587 --- /dev/null +++ b/src/ui/persistedState.ts @@ -0,0 +1,27 @@ +/** + * Reading and writing the persisted blob. + * + * The parsing lives in domain/storage.ts; this is only the part that touches the + * browser. localStorage throws rather than returning null in a private window, + * with site data blocked, or in a WebView that has not finished starting, so + * every access is guarded and failure degrades to defaults. + */ +import { DEFAULTS, STORAGE_KEY, parseStored, serialize } from "../domain/storage"; +import type { PersistedState } from "../domain/storage"; + +export function loadState(): PersistedState { + try { + return parseStored(localStorage.getItem(STORAGE_KEY)); + } catch { + return DEFAULTS; + } +} + +export function saveState(state: PersistedState): void { + try { + localStorage.setItem(STORAGE_KEY, serialize(state)); + } catch { + // Nothing useful to do: the drill still works, it just will not be + // remembered. The backup box in settings is the way out. + } +}