Files
404-Layers/src/App.tsx
ncode 6d1f219e8a Phase 2: Find and Rules
Both tabs were rendering Explore. They now take the whole screen, as the
reference does by hiding the unit on each: a list or a page of prose
wants the room more than the panel does. Picking a search result jumps
to Explore with that combo lit, which is the reference's data-go.

The rules text is extracted verbatim rather than retyped or reworded.
Two bugs on the way there, both worth recording:

- RULES is a template literal that itself contains template literals
  inside a ${} interpolation, so the bracket scanner used for the combo
  table stops at the first inner backtick. The nine title and body pairs
  are matched out of the source instead, with a count assertion so drift
  fails loudly rather than silently dropping a rule.
- PADMAP was read with g() but never added to the list of declarations
  evaluated into the vm, so it was simply not in scope.

The extractor now asserts 9 rules and 4 pad-map rows alongside the 159
combos.

141 tests, typecheck clean. Both tabs verified by rendering them, Find
with a seeded query since headless Firefox cannot type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:49:50 +03:00

335 lines
11 KiB
TypeScript

/**
* The shell. It owns the panel — there is only ever one — and each tab supplies
* 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. 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";
import {
entriesByTarget,
entriesForLayer,
layerCounts,
orderLayerKeys,
} from "./domain/layers";
import type { ContextCode, ControlId, Entry } from "./domain/types";
import { searchEntries } from "./domain/search";
import { DrillCard } from "./ui/DrillCard";
import { DrillStrip } from "./ui/DrillStrip";
import { FindPanel } from "./ui/FindPanel";
import { InfoCard } from "./ui/InfoCard";
import { Panel } from "./ui/Panel";
import { RulesPanel } from "./ui/RulesPanel";
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 (
<svg width="9" height="6" viewBox="0 0 9 6" aria-hidden="true">
<path d="M1 1l3.5 3.5L8 1" fill="none" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
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);
const near = others.filter((e) => e.ctx.some((c) => card.ctx.includes(c)));
const pool = near.length >= 3 ? near : others;
const names: string[] = [];
for (const e of [...pool].sort(() => Math.random() - 0.5)) {
if (!names.includes(e.name)) names.push(e.name);
if (names.length === 3) break;
}
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<Tab>("Explore");
const [sheet, setSheet] = useState<OpenSheet>(null);
// --- Explore ---
const [ctx, setCtx] = useState<ContextCode>(stored.ctx);
const [layer, setLayer] = useState("");
const [selected, setSelected] = useState<Entry | null>(null);
// --- Find ---
const [query, setQuery] = useState("");
const results = useMemo(() => searchEntries(ENTRIES, query), [query]);
/** Jump from a search result to that combo, lit on the panel. */
function goTo(entry: Entry) {
setCtx(entry.ctx[0] ?? "S");
setLayer(entry.key);
setSelected(entry);
setTab("Explore");
}
const counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]);
const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]);
const activeLayer = counts.has(layer) ? layer : "";
const onLayer = useMemo(
() => entriesForLayer(ENTRIES, ctx, activeLayer),
[ctx, activeLayer],
);
const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
// --- Drill ---
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),
[drill.deck, drill.direction],
);
const deal = useCallback(() => {
const card = pickCard(pool, drill.boxes, drill.card);
dispatch({
type: "deal",
card,
...(card && drill.direction === "name" ? { options: optionsFor(card) } : {}),
});
}, [pool, drill.boxes, drill.card, drill.direction]);
// 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 || !drill.answered) return new Map<ControlId, Entry[]>();
return entriesByTarget([drill.card]);
}, [drill.card, drill.answered]);
const isDrill = tab === "Drill";
// Find and Rules take the whole screen: the reference hides the unit on both,
// and a list or a page of prose wants the room more than the panel does.
const isPanelTab = tab === "Explore" || isDrill;
if (!isPanelTab) {
return (
<div className={styles.app}>
{tab === "Find" ? (
<FindPanel query={query} results={results} onQuery={setQuery} onPick={goTo} />
) : (
<RulesPanel />
)}
<nav className={styles.nav}>
{TABS.map((name) => (
<button
key={name}
className={name === tab ? styles.tabOn : styles.tab}
onClick={() => setTab(name)}
>
{name.toUpperCase()}
</button>
))}
</nav>
</div>
);
}
return (
<div className={styles.app}>
{isDrill ? (
<DrillStrip
deck={drill.deck}
known={knownCount(pool, drill.boxes)}
total={pool.length}
distribution={boxDistribution(pool, drill.boxes)}
onSettings={() => setSheet("settings")}
/>
) : (
<div className={styles.strip}>
<button className={styles.picker} onClick={() => setSheet("mode")}>
<b>{CTX[ctx].toUpperCase()}</b>
<Caret />
</button>
<button className={styles.picker} onClick={() => setSheet("layer")}>
<b>{layerLabel(activeLayer)}</b>
<span className={styles.count}>{onLayer.length}</span>
</button>
</div>
)}
<Panel
lit={isDrill ? drillLit : exploreLit}
held={isDrill ? drill.chord : activeLayer === "" ? [] : activeLayer.split("+")}
selected={isDrill ? null : (selected?.tset[0] ?? null)}
onPress={(id) => {
if (isDrill) {
if (drill.answered) deal();
else dispatch({ type: "press", id });
return;
}
const here = exploreLit.get(id);
if (!here || here.length === 0) {
setSelected(null);
return;
}
const at = selected ? here.indexOf(selected) : -1;
setSelected(here[(at + 1) % here.length]!);
}}
screen={
isDrill
? {
top: DECKS[drill.deck].name.toUpperCase(),
right: drill.answered ? (drill.lastCorrect ? "CORRECT" : "MISS") : "",
main: drill.card ? drill.card.name : "—",
chips: [],
}
: {
top: CTX[ctx].toUpperCase(),
right: layerLabel(activeLayer),
main: selected ? selected.short : plural(onLayer.length, "function"),
chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short),
}
}
/>
{isDrill ? (
<DrillCard
state={drill}
onCheck={() => dispatch({ type: "check" })}
onClear={() => dispatch({ type: "clear" })}
onReveal={() => dispatch({ type: "reveal" })}
onNext={deal}
onPickOption={(name) => dispatch({ type: "pickOption", name })}
/>
) : (
<InfoCard entry={selected} ctx={ctx} hint={CTXHINT[ctx]} count={onLayer.length} />
)}
<nav className={styles.nav}>
{TABS.map((name) => (
<button
key={name}
className={name === tab ? styles.tabOn : styles.tab}
onClick={() => setTab(name)}
>
{name.toUpperCase()}
</button>
))}
</nav>
<Sheet open={sheet === "mode"} title="Where you are" onClose={() => setSheet(null)}>
{(Object.keys(CTX) as ContextCode[]).map((code) => (
<SheetOption
key={code}
label={CTX[code]}
hint={CTXHINT[code]}
selected={code === ctx}
onSelect={() => {
setCtx(code);
setSelected(null);
setSheet(null);
}}
/>
))}
</Sheet>
<Sheet
open={sheet === "layer"}
title={`Layers in ${CTX[ctx]}`}
onClose={() => setSheet(null)}
>
{layers.map((key) => (
<SheetOption
key={key || "none"}
label={layerLabel(key)}
hint={plural(counts.get(key) ?? 0, "function")}
selected={key === activeLayer}
onSelect={() => {
setLayer(key);
setSelected(null);
setSheet(null);
}}
/>
))}
</Sheet>
<Sheet
open={sheet === "settings"}
title="Drill settings"
onClose={() => setSheet(null)}
>
<p className={styles.sheetLabel}>Deck</p>
{(Object.keys(DECKS) as DeckId[]).map((id) => (
<SheetOption
key={id}
label={DECKS[id].name}
hint={plural(deckPool(ENTRIES, id, drill.direction).length, "card")}
selected={id === drill.deck}
onSelect={() => dispatch({ type: "setDeck", deck: id })}
/>
))}
<p className={styles.sheetLabel}>Direction</p>
{(
[
["play", "Function → press it"],
["name", "Combo → name it"],
] as [Direction, string][]
).map(([id, label]) => (
<SheetOption
key={id}
label={label}
selected={id === drill.direction}
onSelect={() => dispatch({ type: "setDirection", direction: id })}
/>
))}
<p className={styles.sheetLabel}>Answering</p>
{(
[
["hold", "Hold the combo together"],
["tap", "Tap each, then check"],
] as [InputMode, string][]
).map(([id, label]) => (
<SheetOption
key={id}
label={label}
selected={id === drill.input}
onSelect={() => dispatch({ type: "setInput", input: id })}
/>
))}
</Sheet>
</div>
);
}