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>
This commit is contained in:
@@ -35,7 +35,18 @@ function literalAfter(name) {
|
|||||||
throw new Error(`unbalanced literal: ${name}`);
|
throw new Error(`unbalanced literal: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAMES = ["L", "PL", "GROUPS", "GNAME", "NOLATCH", "MODORDER", "RAW", "CTX", "CTXHINT"];
|
const NAMES = [
|
||||||
|
"L",
|
||||||
|
"PL",
|
||||||
|
"GROUPS",
|
||||||
|
"GNAME",
|
||||||
|
"NOLATCH",
|
||||||
|
"MODORDER",
|
||||||
|
"RAW",
|
||||||
|
"CTX",
|
||||||
|
"CTXHINT",
|
||||||
|
"PADMAP",
|
||||||
|
];
|
||||||
const ctx = vm.createContext({});
|
const ctx = vm.createContext({});
|
||||||
for (const n of NAMES) vm.runInContext(`var ${n} = ${literalAfter(n)};`, ctx);
|
for (const n of NAMES) vm.runInContext(`var ${n} = ${literalAfter(n)};`, ctx);
|
||||||
// The reference adds the 16 pad labels in a loop right after declaring L.
|
// The reference adds the 16 pad labels in a loop right after declaring L.
|
||||||
@@ -73,7 +84,34 @@ writeFileSync(
|
|||||||
`export const CTXHINT: Record<ContextCode, string> = ${j(g("CTXHINT"))};\n`
|
`export const CTXHINT: Record<ContextCode, string> = ${j(g("CTXHINT"))};\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The nine rules live in a template literal that itself contains template
|
||||||
|
// literals inside a ${...} interpolation, so the bracket scanner above would
|
||||||
|
// stop at the first inner backtick. Match the pairs out of the source instead.
|
||||||
|
const rulePairs = [...src.matchAll(/<h3>(.*?)<\/h3><p>(.*?)<\/p>/gs)].map((m) => ({
|
||||||
|
title: m[1].trim(),
|
||||||
|
body: m[2].trim(),
|
||||||
|
}));
|
||||||
|
if (rulePairs.length !== 9) {
|
||||||
|
throw new Error(`expected 9 rules, found ${rulePairs.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const padMap = g("PADMAP").map(([label, pads]) => ({ label, pads }));
|
||||||
|
if (padMap.length !== 4) {
|
||||||
|
throw new Error(`expected 4 pad-map rows, found ${padMap.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
"src/domain/rules.ts",
|
||||||
|
header("The Rules tab: nine rules, and the SHIFT + pad map.") +
|
||||||
|
`export interface Rule {\n readonly title: string;\n readonly body: string;\n}\n\n` +
|
||||||
|
`export interface PadMapRow {\n readonly label: string;\n readonly pads: readonly string[];\n}\n\n` +
|
||||||
|
`export const RULES: readonly Rule[] = ${j(rulePairs)};\n\n` +
|
||||||
|
`export const PAD_MAP: readonly PadMapRow[] = ${j(padMap)};\n`
|
||||||
|
);
|
||||||
|
|
||||||
const raw = g("RAW");
|
const raw = g("RAW");
|
||||||
|
console.log(`rules ${rulePairs.length}`);
|
||||||
|
console.log(`pad-map rows ${padMap.length}`);
|
||||||
console.log(`RAW entries ${raw.length}`);
|
console.log(`RAW entries ${raw.length}`);
|
||||||
console.log(`control labels ${Object.keys(g("L")).length}`);
|
console.log(`control labels ${Object.keys(g("L")).length}`);
|
||||||
console.log(`contexts ${Object.keys(g("CTX")).join(", ")}`);
|
console.log(`contexts ${Object.keys(g("CTX")).join(", ")}`);
|
||||||
|
|||||||
42
src/App.tsx
42
src/App.tsx
@@ -20,10 +20,13 @@ import {
|
|||||||
orderLayerKeys,
|
orderLayerKeys,
|
||||||
} from "./domain/layers";
|
} from "./domain/layers";
|
||||||
import type { ContextCode, ControlId, Entry } from "./domain/types";
|
import type { ContextCode, ControlId, Entry } from "./domain/types";
|
||||||
|
import { searchEntries } from "./domain/search";
|
||||||
import { DrillCard } from "./ui/DrillCard";
|
import { DrillCard } from "./ui/DrillCard";
|
||||||
import { DrillStrip } from "./ui/DrillStrip";
|
import { DrillStrip } from "./ui/DrillStrip";
|
||||||
|
import { FindPanel } from "./ui/FindPanel";
|
||||||
import { InfoCard } from "./ui/InfoCard";
|
import { InfoCard } from "./ui/InfoCard";
|
||||||
import { Panel } from "./ui/Panel";
|
import { Panel } from "./ui/Panel";
|
||||||
|
import { RulesPanel } from "./ui/RulesPanel";
|
||||||
import { Sheet, SheetOption } from "./ui/Sheet";
|
import { Sheet, SheetOption } from "./ui/Sheet";
|
||||||
import { drillReducer, initialDrillState } from "./ui/drillReducer";
|
import { drillReducer, initialDrillState } from "./ui/drillReducer";
|
||||||
import { loadState, saveState } from "./ui/persistedState";
|
import { loadState, saveState } from "./ui/persistedState";
|
||||||
@@ -71,6 +74,18 @@ export function App() {
|
|||||||
const [layer, setLayer] = useState("");
|
const [layer, setLayer] = useState("");
|
||||||
const [selected, setSelected] = useState<Entry | null>(null);
|
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 counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]);
|
||||||
const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]);
|
const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]);
|
||||||
const activeLayer = counts.has(layer) ? layer : "";
|
const activeLayer = counts.has(layer) ? layer : "";
|
||||||
@@ -121,6 +136,33 @@ export function App() {
|
|||||||
}, [drill.card, drill.answered]);
|
}, [drill.card, drill.answered]);
|
||||||
|
|
||||||
const isDrill = tab === "Drill";
|
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 (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
|
|||||||
90
src/domain/rules.ts
Normal file
90
src/domain/rules.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// GENERATED from reference/index.html by scripts/extract-reference-data.mjs — do not edit by hand.
|
||||||
|
// The Rules tab: nine rules, and the SHIFT + pad map.
|
||||||
|
|
||||||
|
export interface Rule {
|
||||||
|
readonly title: string;
|
||||||
|
readonly body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PadMapRow {
|
||||||
|
readonly label: string;
|
||||||
|
readonly pads: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RULES: readonly Rule[] = [
|
||||||
|
{
|
||||||
|
"title": "SHIFT + pad is a 4×4 map, row by row",
|
||||||
|
"body": "Learn the row themes first; the individual pads fall out of them."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "SHIFT + a playback button = the same thing, bigger or weirder",
|
||||||
|
"body": "GATE and BPM SYNC apply to the whole bank. LOOP becomes ping-pong. ROLL sets its rate. HOLD pauses. EXIT stops everything. REC in pattern mode becomes TR-REC."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "SHIFT + an edit button = its sibling screen",
|
||||||
|
"body": "START/END → markers and chop. PITCH/SPEED → envelope. RESAMPLE → merge. RECORD SETTING → synth. SUB PAD → projects. PATTERN SELECT → undo."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "REMAIN means “where does it go, and keep it there”",
|
||||||
|
"body": "With pads, banks or EXT SOURCE it sets bus routing. With an FX button it pins the screen. On edit screens it applies knob moves to the whole bank. Alone it shows pad info."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Holding VALUE means “quietly, or only for now”",
|
||||||
|
"body": "Pads select without sound, FX buttons turn momentary, knobs reach sub-parameters, BUS FX bypasses 3/4 for a moment. Pressing VALUE on any edit screen opens its hidden menu."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "DEL or COPY + EXIT acts on a whole bank",
|
||||||
|
"body": "DEL + EXIT deletes a bank, COPY + EXIT copies it. COPY on edit screens scopes changes to the mute group instead of the bank."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Three-second holds guard things you do not want by accident",
|
||||||
|
"body": "SHIFT + REMAIN (live mode) and SHIFT + MARK (save BUS 1/2 effects)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pattern mode reuses keys by meaning",
|
||||||
|
"body": "HOLD + pad = chains. SUB PAD + pad = switch now. MARK while recording = FX motion; SHIFT + MARK erases it. DEL while recording = erase notes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Lost? EXIT four times.",
|
||||||
|
"body": "Stops everything and clears pad mutes. EXIT alone backs out one screen."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PAD_MAP: readonly PadMapRow[] = [
|
||||||
|
{
|
||||||
|
"label": "Row 1: how pads play",
|
||||||
|
"pads": [
|
||||||
|
"Fixed vel",
|
||||||
|
"16 vel",
|
||||||
|
"Cue",
|
||||||
|
"Chromatic"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Row 2: organise pads",
|
||||||
|
"pads": [
|
||||||
|
"Exchange",
|
||||||
|
"Init",
|
||||||
|
"Link",
|
||||||
|
"Mute group"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Row 3: time and input",
|
||||||
|
"pads": [
|
||||||
|
"Metronome",
|
||||||
|
"Count-in",
|
||||||
|
"Tempo / tap",
|
||||||
|
"Gain"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Row 4: menus",
|
||||||
|
"pads": [
|
||||||
|
"Utility",
|
||||||
|
"Import/export",
|
||||||
|
"Pad set",
|
||||||
|
"EFX set"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
105
src/ui/FindPanel.module.css
Normal file
105
src/ui/FindPanel.module.css
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/* Takes the space the panel would use, and scrolls. */
|
||||||
|
|
||||||
|
.find {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--strip-h);
|
||||||
|
background: var(--card);
|
||||||
|
border: var(--outline);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 1px 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 9px;
|
||||||
|
background: var(--card);
|
||||||
|
border: var(--outline);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 2;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.where {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 8.5px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combo {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9.5px;
|
||||||
|
color: var(--accent);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
65
src/ui/FindPanel.tsx
Normal file
65
src/ui/FindPanel.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Find: search everything printed about a combo, then jump to it on the panel.
|
||||||
|
*
|
||||||
|
* Fills the whole screen rather than sitting under the panel — the reference
|
||||||
|
* hides the unit on this tab, and a result list wants the room.
|
||||||
|
*/
|
||||||
|
import { comboText, contextNames } from "../domain/format";
|
||||||
|
import type { Entry } from "../domain/types";
|
||||||
|
import styles from "./FindPanel.module.css";
|
||||||
|
|
||||||
|
export interface FindPanelProps {
|
||||||
|
readonly query: string;
|
||||||
|
readonly results: readonly Entry[];
|
||||||
|
readonly onQuery: (query: string) => void;
|
||||||
|
readonly onPick: (entry: Entry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindPanel({ query, results, onQuery, onPick }: FindPanelProps) {
|
||||||
|
const searched = query.trim() !== "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.find}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
placeholder="undo, chop, metronome, side chain…"
|
||||||
|
aria-label="Search functions"
|
||||||
|
enterKeyHint="search"
|
||||||
|
onChange={(e) => onQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!searched ? (
|
||||||
|
<p className={styles.note}>
|
||||||
|
Search by function, by button, or by the mode it belongs to. Pick a result
|
||||||
|
to see it lit on the panel.
|
||||||
|
</p>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<p className={styles.note}>
|
||||||
|
No match. Try the name as it appears on screen — CHOP, TR-REC, MUTE BUS.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={styles.count}>
|
||||||
|
{results.length} match{results.length === 1 ? "" : "es"}
|
||||||
|
</p>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{results.map((entry) => (
|
||||||
|
<li key={entry.id}>
|
||||||
|
<button className={styles.row} onClick={() => onPick(entry)}>
|
||||||
|
<span className={styles.name}>{entry.name}</span>
|
||||||
|
<span className={styles.where}>
|
||||||
|
{contextNames(entry.ctx).join(", ")}
|
||||||
|
</span>
|
||||||
|
<span className={styles.combo}>{comboText(entry)}</span>
|
||||||
|
<span className={styles.page}>P.{entry.page}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/ui/RulesPanel.module.css
Normal file
88
src/ui/RulesPanel.module.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/* Takes the space the panel would use, and scrolls. */
|
||||||
|
|
||||||
|
.rules {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
background: var(--card);
|
||||||
|
border: var(--outline);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapHead {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapLabel {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 8.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
background: var(--page);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
padding: 4px 5px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
line-height: 1.15;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellNum {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule {
|
||||||
|
background: var(--card);
|
||||||
|
border: var(--outline);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0 0 3px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--muted);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
38
src/ui/RulesPanel.tsx
Normal file
38
src/ui/RulesPanel.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Rules: the nine patterns behind the combos, and the SHIFT + pad map.
|
||||||
|
*
|
||||||
|
* The text is extracted verbatim from the reference, never retyped — see
|
||||||
|
* scripts/extract-reference-data.mjs.
|
||||||
|
*/
|
||||||
|
import { PAD_MAP, RULES } from "../domain/rules";
|
||||||
|
import styles from "./RulesPanel.module.css";
|
||||||
|
|
||||||
|
export function RulesPanel() {
|
||||||
|
return (
|
||||||
|
<div className={styles.rules}>
|
||||||
|
<section className={styles.map}>
|
||||||
|
<p className={styles.mapHead}>SHIFT + PAD, ROW BY ROW</p>
|
||||||
|
{PAD_MAP.map((row, rowIndex) => (
|
||||||
|
<div key={row.label} className={styles.mapRow}>
|
||||||
|
<p className={styles.mapLabel}>{row.label}</p>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{row.pads.map((pad, i) => (
|
||||||
|
<div key={pad} className={styles.cell}>
|
||||||
|
<b className={styles.cellNum}>{rowIndex * 4 + i + 1}</b>
|
||||||
|
{pad}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{RULES.map((rule) => (
|
||||||
|
<section key={rule.title} className={styles.rule}>
|
||||||
|
<h3 className={styles.title}>{rule.title}</h3>
|
||||||
|
<p className={styles.body}>{rule.body}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user