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:
191
src/App.tsx
191
src/App.tsx
@@ -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) => (
|
||||
|
||||
168
src/ui/DrillCard.module.css
Normal file
168
src/ui/DrillCard.module.css
Normal file
@@ -0,0 +1,168 @@
|
||||
/* Same 84px box as Explore's info card, which leaves 68px of content once the
|
||||
2px borders and 6px padding are taken out. Every state has to fit that:
|
||||
|
||||
asking 21 meta + 3 + 15 title + 3 + 21 row = 63
|
||||
graded 12 meta + 3 + 21 row + 3 + 28 body (2 lines) = 67
|
||||
naming 12 meta + 3 + 45 options (2 rows of 20.5) = 60
|
||||
|
||||
The first version overflowed by 8.5px because a 10px button with 5px padding
|
||||
and a 2px border is 26px tall, and two rows of those plus the title do not
|
||||
fit. Buttons are 21px now. */
|
||||
|
||||
.card {
|
||||
height: var(--info-h);
|
||||
flex: 0 0 auto;
|
||||
background: var(--card);
|
||||
border: var(--outline);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* No green or red in this palette: right is the accent, wrong is an outline. */
|
||||
.verdict {
|
||||
font-family: var(--font);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.verdictMiss {
|
||||
color: var(--muted);
|
||||
border: 1px dashed var(--muted);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.combo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--ink);
|
||||
color: var(--page);
|
||||
padding: 1px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plus {
|
||||
font-style: normal;
|
||||
margin: 0 2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
color: var(--muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pressed {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 21px tall: 11px of text, 6px padding, 4px border. */
|
||||
.primary,
|
||||
.ghost {
|
||||
flex: 0 0 auto;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.15;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 3px 9px;
|
||||
border: var(--outline);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--ink);
|
||||
color: var(--page);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.primary:disabled,
|
||||
.ghost:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Combo-first cards: four names to choose between, two rows of 20.5px. */
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.option {
|
||||
background: var(--page);
|
||||
border: var(--outline);
|
||||
color: var(--ink);
|
||||
font-size: 9.5px;
|
||||
line-height: 1.15;
|
||||
text-align: left;
|
||||
padding: 4px 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
123
src/ui/DrillCard.tsx
Normal file
123
src/ui/DrillCard.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Drill's card area, in the same 84px box Explore uses for its info card.
|
||||
*
|
||||
* Three states: asking you to press a combo, asking you to name one, or showing
|
||||
* the verdict. Feedback is orange or an outline — the palette has no green or red.
|
||||
*/
|
||||
import { comboParts } from "../domain/format";
|
||||
import type { Entry } from "../domain/types";
|
||||
import type { DrillState } from "./drillReducer";
|
||||
import { cx } from "./cx";
|
||||
import styles from "./DrillCard.module.css";
|
||||
|
||||
export interface DrillCardProps {
|
||||
readonly state: DrillState;
|
||||
readonly onCheck: () => void;
|
||||
readonly onClear: () => void;
|
||||
readonly onReveal: () => void;
|
||||
readonly onNext: () => void;
|
||||
readonly onPickOption: (name: string) => void;
|
||||
}
|
||||
|
||||
function Chips({ entry }: { entry: Entry }) {
|
||||
return (
|
||||
<span className={styles.combo}>
|
||||
{comboParts(entry).map((part, i) => (
|
||||
<span key={part}>
|
||||
{i > 0 ? <i className={styles.plus}>+</i> : null}
|
||||
<b className={styles.chip}>{part}</b>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrillCard({
|
||||
state,
|
||||
onCheck,
|
||||
onClear,
|
||||
onReveal,
|
||||
onNext,
|
||||
onPickOption,
|
||||
}: DrillCardProps) {
|
||||
const { card, chord, answered, lastCorrect, direction, input } = state;
|
||||
|
||||
if (!card) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<p className={styles.body}>No cards in this deck.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (answered) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.meta}>
|
||||
<span className={cx(styles.verdict, !lastCorrect && styles.verdictMiss)}>
|
||||
{lastCorrect ? "CORRECT" : "NOT QUITE"}
|
||||
</span>
|
||||
<span>P.{card.page}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<Chips entry={card} />
|
||||
<button className={styles.primary} onClick={onNext}>
|
||||
NEXT
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.body}>{card.desc || card.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (direction === "name") {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.meta}>
|
||||
<Chips entry={card} />
|
||||
<span>WHICH IS IT?</span>
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
{state.options.map((name) => (
|
||||
<button key={name} className={styles.option} onClick={() => onPickOption(name)}>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.ellipsis}>
|
||||
{input === "hold" ? "HOLD THE COMBO" : "TAP EACH, THEN CHECK"}
|
||||
</span>
|
||||
<button className={styles.ghost} onClick={onReveal}>
|
||||
SHOW ME
|
||||
</button>
|
||||
</div>
|
||||
<h2 className={styles.title}>{card.name}</h2>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.pressed}>
|
||||
{chord.length === 0 ? "—" : chord.join(" + ").toUpperCase()}
|
||||
</span>
|
||||
{input === "tap" ? (
|
||||
<span className={styles.row}>
|
||||
<button className={styles.ghost} onClick={onClear} disabled={chord.length === 0}>
|
||||
CLEAR
|
||||
</button>
|
||||
<button
|
||||
className={styles.primary}
|
||||
onClick={onCheck}
|
||||
disabled={chord.length === 0}
|
||||
>
|
||||
CHECK
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/ui/DrillStrip.module.css
Normal file
65
src/ui/DrillStrip.module.css
Normal file
@@ -0,0 +1,65 @@
|
||||
.strip {
|
||||
height: var(--strip-h);
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
}
|
||||
|
||||
.deck {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: var(--card);
|
||||
border: var(--outline);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress {
|
||||
flex: 0 0 104px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: var(--card);
|
||||
border: var(--outline);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
/* Five bars, one per Leitner box; the fifth is what "known" counts. */
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.bar,
|
||||
.barTop {
|
||||
width: 5px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.barTop {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
53
src/ui/DrillStrip.tsx
Normal file
53
src/ui/DrillStrip.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/** Drill's strip: which deck, and how far through it you are. */
|
||||
import { DECKS } from "../domain/drill";
|
||||
import type { DeckId } from "../domain/drill";
|
||||
import styles from "./DrillStrip.module.css";
|
||||
|
||||
export interface DrillStripProps {
|
||||
readonly deck: DeckId;
|
||||
readonly known: number;
|
||||
readonly total: number;
|
||||
/** Cards per Leitner box, 1 to 5. */
|
||||
readonly distribution: readonly [number, number, number, number, number];
|
||||
readonly onSettings: () => void;
|
||||
}
|
||||
|
||||
export function DrillStrip({
|
||||
deck,
|
||||
known,
|
||||
total,
|
||||
distribution,
|
||||
onSettings,
|
||||
}: DrillStripProps) {
|
||||
const tallest = Math.max(1, ...distribution);
|
||||
|
||||
return (
|
||||
<div className={styles.strip}>
|
||||
<button className={styles.deck} onClick={onSettings}>
|
||||
<b className={styles.name}>{DECKS[deck].name.toUpperCase()}</b>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<div className={styles.progress}>
|
||||
<span
|
||||
className={styles.bars}
|
||||
role="img"
|
||||
aria-label={`${known} of ${total} known`}
|
||||
>
|
||||
{distribution.map((count, box) => (
|
||||
<i
|
||||
key={box}
|
||||
className={box === 4 ? styles.barTop : styles.bar}
|
||||
style={{ height: `${Math.max(2, (count / tallest) * 16)}px` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className={styles.score}>
|
||||
{known}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user