diff --git a/CLAUDE.md b/CLAUDE.md index 01e0c3b..95887c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,8 +179,25 @@ src/domain/ format.ts ids to printed words, and the "SHIFT + PAD 1" lines search.ts the Find tab's matcher storage.ts the persisted blob, backup parsing, defaults + +src/ui/ + panelLayout.ts where every control sits; captions as silkscreened + Panel.tsx the panel, presentational: told what is lit, reports presses + cx.ts joins class names, dropping blanks +src/styles/base.css tokens: palette, fonts, the vertical budget ``` +**CSS modules type as `string | undefined`** under `noUncheckedIndexedAccess`, so +`cx()` absorbs that rather than the flag being turned off — it has caught real +bugs. The cost is that a mistyped class renders with no class at all rather than +failing, which is why the layout is pinned by tests instead. + +**`panelLayout.ts` is data, and tested.** `allPanelControls()` must cover all 54 +labelled controls exactly once — a typo'd id would otherwise just quietly go +missing from the panel. The bank row is pinned at six keys ending in SHIFT +because `Panel.module.css` widens it with `:last-child:nth-child(6)`, which stops +matching silently if that row ever changes length. + **Two strings are load-bearing for migration** and must not be changed casually: `Entry.id` (below) and `STORAGE_KEY`, still `sp404mk2-layers-v1`. Together they mean the boxes already on the phone are picked up by the React app rather than diff --git a/src/App.tsx b/src/App.tsx index abe98b4..c888aa1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,21 +1,22 @@ /** - * App shell: the two pickers, a placeholder panel, and the tab bar. + * App shell: pickers, the panel, and the tab bar. * * Angular: useState is roughly a component field plus change detection. Setting - * it re-runs this function, so `entries` and `counts` below are recomputed each - * render — cheap here, and `useMemo` is the explicit opt-out if it stops being. + * it re-runs this function, so derived values below are recomputed each render; + * useMemo is the explicit opt-out when that stops being cheap. */ import { useMemo, useState } from "react"; import { ENTRIES } from "./domain/entries"; -import { CTX } from "./domain/labels"; import { controlName } from "./domain/format"; -import { layerCounts, orderLayerKeys } from "./domain/layers"; +import { CTX } from "./domain/labels"; +import { entriesByTarget, entriesForLayer, layerCounts, orderLayerKeys } from "./domain/layers"; import type { ContextCode } from "./domain/types"; +import { Panel } from "./ui/Panel"; import styles from "./App.module.css"; const TABS = ["Explore", "Drill", "Find", "Rules"] as const; -/** Drawn, not typed: the caret glyphs sit outside every Latin font subset. */ +/** Drawn, not typed: caret glyphs sit outside every Latin font subset. */ function Caret() { return (
@@ -60,15 +69,20 @@ export function App() { }} > {layerLabel(activeLayer)} - {counts.get(activeLayer) ?? 0} + {onLayer.length}
-
-

- {counts.get(activeLayer) ?? 0} functions on this layer -

-
+ e.short), + }} + />
diff --git a/src/domain/format.ts b/src/domain/format.ts index 666b36a..c0a2d27 100644 --- a/src/domain/format.ts +++ b/src/domain/format.ts @@ -1,5 +1,5 @@ /** Turning ids into the words printed on the panel and in combo lines. */ -import { CTX, GNAME, GROUPS, L } from "./labels"; +import { CTX, GNAME, GROUPS, L, PL } from "./labels"; import type { ContextCode, ControlId, Entry, GroupId, RawEntry } from "./types"; const GROUP_IDS: ReadonlySet = new Set(Object.keys(GROUPS)); @@ -9,6 +9,11 @@ export function controlName(id: ControlId): string { return L[id] ?? id; } +/** Label printed on the key itself, shortened where the full name won't fit. */ +export function panelLabel(id: ControlId): string { + return PL[id] ?? controlName(id); +} + /** * Name of whatever an entry targets: one control, a group ("PAD 1–16"), or an * explicit list joined with a slash. diff --git a/src/ui/Panel.module.css b/src/ui/Panel.module.css new file mode 100644 index 0000000..2159107 --- /dev/null +++ b/src/ui/Panel.module.css @@ -0,0 +1,253 @@ +/* Measured geometry: 8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8 = 525. */ + +.panel { + height: var(--panel-h); + padding: 8px; + background: var(--body); + border: var(--outline); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; +} + +/* ---- shared key look ---- */ + +.key, +.pad, +.side, +.fx, +.knob, +.valueKnob { + position: relative; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + border: var(--outline); + background: var(--key); + color: var(--ink); + /* 7px with no tracking: a left-block key is 35.9px wide and only 27.9px of + that is usable, so 8px made SAMPLE sit flush against the border. */ + font-size: 7px; + line-height: 1.05; + letter-spacing: 0; + padding: 2px 1px; + border-radius: 3px; + hyphens: manual; + overflow-wrap: break-word; +} + +.lit { + background: var(--accent); + color: var(--on-accent); + font-weight: 700; +} + +.held { + background: var(--ink); + color: var(--page); + font-weight: 700; +} + +.badge { + position: absolute; + top: 1px; + right: 2px; + font-family: var(--mono); + font-size: 6px; + opacity: 0.8; +} + +/* ---- knobs ---- */ + +.knobs { + height: 36px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 4px; +} + +.knob { + width: 34px; + height: 34px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--pad); + color: var(--pad-ink); +} + +/* ---- fx and the screen ---- */ + +.screenRow { + height: 124px; + margin-top: 8px; + display: grid; + grid-template-columns: 62px minmax(0, 1fr) 62px; + gap: 8px; +} + +.fxCol { + display: flex; + flex-direction: column; + gap: 5px; +} + +.fx { + flex: 1; + background: var(--pad); + color: var(--accent); + font-weight: 700; + font-size: 7.5px; + border-radius: 10px; +} + +.screenWrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.circle { + position: absolute; + left: 50%; + top: 0; + width: 124px; + height: 124px; + margin-left: -62px; + border-radius: 50%; + background: var(--pad); + border: var(--outline); +} + +.lcd { + position: relative; + width: 168px; + height: 84px; + background: var(--lcd); + border: var(--outline); + color: #111111; + padding: 4px 6px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.lcdRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 3px; + font-family: var(--mono); + font-size: 6.5px; + letter-spacing: 0.06em; +} + +.lcdMain { + font-size: 17px; + font-weight: 700; + line-height: 1; + text-align: center; +} + +.chip { + background: #111111; + color: var(--lcd); + padding: 1px 4px; +} + +/* ---- control block ---- */ + +.ctl { + height: 134px; + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 7px; +} + +.group { + height: 40px; + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 5fr); + gap: 10px; +} + +.col { + display: flex; + flex-direction: column; +} + +.caption { + height: 8px; + display: flex; + align-items: center; + gap: 4px; + font-family: var(--mono); + font-size: 6px; + letter-spacing: 0.11em; + color: var(--muted); + white-space: nowrap; +} + +.rule { + flex: 1; + height: 1px; + background: var(--rule); +} + +.btnRow { + height: 32px; + display: flex; + gap: 5px; +} + +.key { + flex: 1; + min-width: 0; +} + +/* SHIFT is the longest label in the six-button bank row, as on the hardware. */ +.btnRow .key:last-child:nth-child(6) { + flex: 1.25; +} + +.valueKnob { + width: 30px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--pad); + color: var(--pad-ink); + font-size: 6.5px; +} + +/* ---- pads ---- */ + +.pads { + height: 187px; + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.padRow { + flex: 1; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) 0.9fr; + gap: 5px; +} + +.pad, +.side { + background: var(--pad); + color: var(--pad-ink); + font-size: 9px; + border-radius: 4px; +} + +.side { + color: var(--accent); + font-weight: 700; +} diff --git a/src/ui/Panel.tsx b/src/ui/Panel.tsx new file mode 100644 index 0000000..02c2cad --- /dev/null +++ b/src/ui/Panel.tsx @@ -0,0 +1,144 @@ +/** + * The SP-404MK2 panel. Presentational: it is told what is lit and reports + * presses, holding no state of its own. + */ +import { panelLabel } from "../domain/format"; +import type { ControlId, Entry } from "../domain/types"; +import { cx } from "./cx"; +import { CONTROL_GROUPS, FX_LEFT, FX_RIGHT, KNOBS, PAD_ROWS } from "./panelLayout"; +import styles from "./Panel.module.css"; + +export interface ScreenText { + readonly top: string; + readonly right: string; + readonly main: string; + /** Inverted chips along the bottom of the LCD — the layer's functions. */ + readonly chips: readonly string[]; +} + +export interface PanelProps { + /** Controls carrying a function on the current layer, and which entries. */ + readonly lit: ReadonlyMap; + readonly held: readonly ControlId[]; + readonly screen: ScreenText; + readonly onPress?: (id: ControlId) => void; +} + +interface KeyProps extends Pick { + readonly id: ControlId; + /** Comes from a CSS module, so possibly undefined. See cx. */ + readonly className: string | undefined; +} + +function Key({ id, className, lit, held, onPress }: KeyProps) { + const entries = lit.get(id); + const isHeld = held.includes(id); + const isLit = entries !== undefined && entries.length > 0; + + return ( + + ); +} + +export function Panel({ lit, held, screen, onPress }: PanelProps) { + const key = { lit, held, ...(onPress ? { onPress } : {}) }; + + return ( +
+
+ {KNOBS.map((id) => ( + + ))} +
+ +
+
+ {FX_LEFT.map((id) => ( + + ))} +
+ + {/* The circle sits behind the display and shows only as caps above and + below it — no ring around the rectangle. */} +
+ + +
+ {FX_RIGHT.map((id) => ( + + ))} +
+
+ +
+ {CONTROL_GROUPS.map((group) => ( +
+
+
+ {group.leftCaption ? {group.leftCaption} : null} + +
+
+ {group.left.map((id) => ( + + ))} +
+
+ +
+
+ {group.rightCaption ? {group.rightCaption} : null} + +
+
+ {group.right.map((id) => ( + + ))} + {group.rightKnob ? ( + + ) : null} +
+
+
+ ))} +
+ +
+ {PAD_ROWS.map((row) => ( +
+ {row.pads.map((id) => ( + + ))} + +
+ ))} +
+
+ ); +} diff --git a/src/ui/cx.ts b/src/ui/cx.ts new file mode 100644 index 0000000..bd5d963 --- /dev/null +++ b/src/ui/cx.ts @@ -0,0 +1,9 @@ +/** + * Joins class names, dropping blanks. + * + * CSS-module lookups type as `string | undefined` under + * `noUncheckedIndexedAccess`, so this is where that gets absorbed instead of + * weakening the flag. + */ +export const cx = (...parts: (string | false | undefined)[]): string => + parts.filter((p): p is string => typeof p === "string" && p !== "").join(" "); diff --git a/src/ui/panelLayout.test.ts b/src/ui/panelLayout.test.ts new file mode 100644 index 0000000..c6d6498 --- /dev/null +++ b/src/ui/panelLayout.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { panelLabel } from "../domain/format"; +import { L } from "../domain/labels"; +import { CONTROL_GROUPS, KNOBS, PAD_ROWS, allPanelControls } from "./panelLayout"; + +describe("the panel layout", () => { + const drawn = allPanelControls(); + + it("draws every labelled control exactly once", () => { + expect(new Set(drawn).size).toBe(drawn.length); + expect(new Set(drawn)).toEqual(new Set(Object.keys(L))); + }); + + it("draws all 54 of them", () => { + expect(drawn).toHaveLength(54); + expect(Object.keys(L)).toHaveLength(54); + }); + + it("gives every control something printable", () => { + for (const id of drawn) expect(panelLabel(id)).not.toBe(""); + }); + + it("lays the pads out 1-16 across four rows", () => { + expect(PAD_ROWS).toHaveLength(4); + expect(PAD_ROWS.flatMap((r) => r.pads)).toEqual( + Array.from({ length: 16 }, (_, i) => `p${i + 1}`), + ); + }); + + it("puts one side key beside each pad row", () => { + expect(PAD_ROWS.map((r) => r.side)).toEqual(["busfx", "hold", "ext", "subpad"]); + }); + + it("keeps the bank row six keys long, ending in SHIFT", () => { + // Panel.module.css widens SHIFT with `:last-child:nth-child(6)`. Change the + // length of this row and that rule silently stops matching. + const bank = CONTROL_GROUPS[2]!.right; + expect(bank).toHaveLength(6); + expect(bank[5]).toBe("shift"); + }); + + it("puts the VALUE encoder on the first group only", () => { + expect(CONTROL_GROUPS[0]!.rightKnob).toBe("value"); + expect(CONTROL_GROUPS[1]!.rightKnob).toBeUndefined(); + expect(CONTROL_GROUPS[2]!.rightKnob).toBeUndefined(); + }); + + it("captions the groups as the hardware is silkscreened", () => { + expect(CONTROL_GROUPS.map((g) => g.leftCaption)).toEqual([ + "PATTERN SEQUENCER", + "SAMPLING", + "", + ]); + expect(CONTROL_GROUPS.map((g) => g.rightCaption)).toEqual([ + "SAMPLE EDIT", + "", + "BANK", + ]); + }); + + it("starts with the four knobs", () => { + expect(KNOBS).toEqual(["vol", "c1", "c2", "c3"]); + }); +}); diff --git a/src/ui/panelLayout.ts b/src/ui/panelLayout.ts new file mode 100644 index 0000000..dc58765 --- /dev/null +++ b/src/ui/panelLayout.ts @@ -0,0 +1,71 @@ +/** + * Where every control sits on the panel. + * + * Kept as data so the component stays readable and the arrangement can be + * checked. Captions are the ones silkscreened on the hardware; blank ones still + * reserve their row so the three groups line up. + */ +import type { ControlId } from "../domain/types"; + +export const KNOBS: readonly ControlId[] = ["vol", "c1", "c2", "c3"]; + +export const FX_LEFT: readonly ControlId[] = ["fx_fd", "fx_res", "fx_dly"]; +export const FX_RIGHT: readonly ControlId[] = ["fx_iso", "fx_djl", "mfx"]; + +export interface ControlGroup { + readonly leftCaption: string; + readonly left: readonly ControlId[]; + readonly rightCaption: string; + readonly right: readonly ControlId[]; + /** The round VALUE encoder, which ends the first group's right-hand row. */ + readonly rightKnob?: ControlId; +} + +export const CONTROL_GROUPS: readonly ControlGroup[] = [ + { + leftCaption: "PATTERN SEQUENCER", + left: ["pat_sel", "pat_edit", "rec_set"], + rightCaption: "SAMPLE EDIT", + right: ["start_end", "pitch", "mark"], + rightKnob: "value", + }, + { + leftCaption: "SAMPLING", + left: ["del", "rec", "resample"], + rightCaption: "", + right: ["bpm_sync", "gate", "loop", "reverse", "roll"], + }, + { + leftCaption: "", + left: ["exit", "copy", "remain"], + rightCaption: "BANK", + right: ["bank_a", "bank_b", "bank_c", "bank_d", "bank_e", "shift"], + }, +]; + +export interface PadRow { + readonly pads: readonly ControlId[]; + readonly side: ControlId; +} + +export const PAD_ROWS: readonly PadRow[] = [ + { pads: ["p1", "p2", "p3", "p4"], side: "busfx" }, + { pads: ["p5", "p6", "p7", "p8"], side: "hold" }, + { pads: ["p9", "p10", "p11", "p12"], side: "ext" }, + { pads: ["p13", "p14", "p15", "p16"], side: "subpad" }, +]; + +/** Every control the panel draws, for checking nothing is missing. */ +export function allPanelControls(): ControlId[] { + return [ + ...KNOBS, + ...FX_LEFT, + ...FX_RIGHT, + ...CONTROL_GROUPS.flatMap((g) => [ + ...g.left, + ...g.right, + ...(g.rightKnob ? [g.rightKnob] : []), + ]), + ...PAD_ROWS.flatMap((r) => [...r.pads, r.side]), + ]; +}