Phase 2: the Drill screen

App now owns the panel once and each tab supplies what is lit, what the
screen says, and what fills the strip and the 84px card box. Drill adds
its strip (deck plus the five Leitner bars) and its card, which has three
states: press this combo, name this combo, or the verdict.

Two defects fixed that only rendering could show.

Dealing the first card happened during render. React tolerated it and
the capture looked fine, but dealing picks at random and StrictMode
renders twice, so it was fragile rather than wrong today. It is an
effect now.

The card box overflowed by 8.5px and clipped CLEAR and CHECK. The box is
84px, which leaves 68px once borders and padding are removed, and a 10px
button with 5px padding and a 2px border is 26px tall - two rows of those
plus the title do not fit. Buttons are 21px now, and the stylesheet
records the budget for all three states so the next change has to do the
arithmetic rather than guess.

Verified by rendering the asking state and the answered state, the
latter by temporarily forcing a graded card and restoring the source
afterwards. Typecheck clean, 132 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:43:19 +03:00
parent 6f1540635e
commit 9e1ffa0c85
5 changed files with 542 additions and 58 deletions

View File

@@ -1,11 +1,12 @@
/**
* Explore: pick a mode and a layer, then read what the panel lights up.
* 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: useState is roughly a component field plus change detection. Setting
* it re-runs this function, so derived values are recomputed each render;
* useMemo is the explicit opt-out when that stops being cheap.
* Angular: useReducer is the closest thing to a service holding state with
* methods, except dispatch returns a new state rather than mutating one.
*/
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill";
import { ENTRIES } from "./domain/entries";
import { controlName } from "./domain/format";
import { CTX, CTXHINT } from "./domain/labels";
@@ -16,13 +17,16 @@ import {
orderLayerKeys,
} from "./domain/layers";
import type { ContextCode, ControlId, Entry } from "./domain/types";
import { DrillCard } from "./ui/DrillCard";
import { DrillStrip } from "./ui/DrillStrip";
import { InfoCard } from "./ui/InfoCard";
import { Panel } from "./ui/Panel";
import { drillReducer, initialDrillState } from "./ui/drillReducer";
import styles from "./App.module.css";
const TABS = ["Explore", "Drill", "Find", "Rules"] as const;
type Tab = (typeof TABS)[number];
/** Drawn, not typed: caret glyphs sit outside every Latin font subset. */
function Caret() {
return (
<svg width="9" height="6" viewBox="0 0 9 6" aria-hidden="true">
@@ -34,82 +38,153 @@ function Caret() {
const layerLabel = (key: string): string =>
key === "" ? "NO MODIFIER" : key.split("+").map(controlName).join(" + ");
/** 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);
}
export function App() {
const [tab, setTab] = useState<Tab>("Explore");
// --- Explore ---
const [ctx, setCtx] = useState<ContextCode>("S");
const [layer, setLayer] = useState("");
const [selected, setSelected] = useState<Entry | null>(null);
const [tab, setTab] = useState<(typeof TABS)[number]>("Explore");
const counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]);
const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]);
// Changing mode can strip the held layer out from under us.
const activeLayer = counts.has(layer) ? layer : "";
const onLayer = useMemo(
() => entriesForLayer(ENTRIES, ctx, activeLayer),
[ctx, activeLayer],
);
const lit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
const held = activeLayer === "" ? [] : activeLayer.split("+");
// --- Drill ---
const [drill, dispatch] = useReducer(
drillReducer,
initialDrillState("shiftpads", "play", "tap", {}),
);
const pool = useMemo(
() => deckPool(ENTRIES, drill.deck, drill.direction),
[drill.deck, drill.direction],
);
/** A control can carry several functions; pressing it again cycles them. */
function press(id: ControlId) {
const here = lit.get(id);
if (!here || here.length === 0) {
setSelected(null);
return;
}
const at = selected ? here.indexOf(selected) : -1;
setSelected(here[(at + 1) % here.length]!);
}
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]);
function chooseCtx() {
const order = Object.keys(CTX) as ContextCode[];
setCtx(order[(order.indexOf(ctx) + 1) % order.length]!);
setSelected(null);
}
// 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.
useEffect(() => {
if (tab === "Drill" && drill.card === null && pool.length > 0) deal();
}, [tab, drill.card, pool, deal]);
function chooseLayer() {
const at = layers.indexOf(activeLayer);
setLayer(layers[(at + 1) % layers.length]!);
setSelected(null);
}
const drillLit = useMemo(() => {
if (!drill.card) return new Map<ControlId, Entry[]>();
if (drill.answered) return entriesByTarget([drill.card]);
return new Map<ControlId, Entry[]>();
}, [drill.card, drill.answered]);
const isDrill = tab === "Drill";
return (
<div className={styles.app}>
<div className={styles.strip}>
<button className={styles.picker} onClick={chooseCtx}>
<b>{CTX[ctx].toUpperCase()}</b>
<Caret />
</button>
<button className={styles.picker} onClick={chooseLayer}>
<b>{layerLabel(activeLayer)}</b>
<span className={styles.count}>{onLayer.length}</span>
</button>
</div>
{isDrill ? (
<DrillStrip
deck={drill.deck}
known={knownCount(pool, drill.boxes)}
total={pool.length}
distribution={boxDistribution(pool, drill.boxes)}
onSettings={() => undefined}
/>
) : (
<div className={styles.strip}>
<button
className={styles.picker}
onClick={() => {
const order = Object.keys(CTX) as ContextCode[];
setCtx(order[(order.indexOf(ctx) + 1) % order.length]!);
setSelected(null);
}}
>
<b>{CTX[ctx].toUpperCase()}</b>
<Caret />
</button>
<button
className={styles.picker}
onClick={() => {
const at = layers.indexOf(activeLayer);
setLayer(layers[(at + 1) % layers.length]!);
setSelected(null);
}}
>
<b>{layerLabel(activeLayer)}</b>
<span className={styles.count}>{onLayer.length}</span>
</button>
</div>
)}
<Panel
lit={lit}
held={held}
selected={selected?.tset[0] ?? null}
onPress={press}
screen={{
top: CTX[ctx].toUpperCase(),
right: layerLabel(activeLayer),
main: selected ? selected.short : `${onLayer.length} functions`,
chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short),
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 : `${onLayer.length} functions`,
chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short),
}
}
/>
<InfoCard
entry={selected}
ctx={ctx}
hint={CTXHINT[ctx]}
count={onLayer.length}
/>
{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) => (