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>
335 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|