Phase 2: the panel

Panel.tsx is presentational - told what is lit, reports presses, holds no
state. The arrangement lives in panelLayout.ts as data, so the component
stays readable and the layout can be tested.

panelLayout.test.ts asserts allPanelControls covers all 54 labelled
controls exactly once; a typo'd id would otherwise quietly vanish from
the panel. It also pins the bank row at six keys ending in SHIFT, because
Panel.module.css widens that key with :last-child:nth-child(6), a
coupling that stops matching silently if the row changes length.

Geometry is the measured one: 8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8
= 525. The screen is the hardware's arrangement - a 124px circle behind a
168x84 LCD, showing only as caps above and below, with no ring around the
display. Key labels are 7px with no tracking, which is what stops
RESAMPLE overflowing its 35.9px button.

cx() absorbs CSS-module lookups typing as string | undefined under
noUncheckedIndexedAccess, rather than turning that flag off - it has
already caught real bugs.

Two fixed on the way through: panelLabel used PL without importing it,
which broke typecheck and the layout test at runtime; and ten className
type errors from the same CSS-module typing, in the places that needed a
definite string rather than React's optional one.

112 tests, build clean, www/ untouched so the Phase 1 APK still installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 01:12:09 +03:00
parent 956dc57763
commit 563caf0658
8 changed files with 590 additions and 13 deletions

144
src/ui/Panel.tsx Normal file
View File

@@ -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<ControlId, Entry[]>;
readonly held: readonly ControlId[];
readonly screen: ScreenText;
readonly onPress?: (id: ControlId) => void;
}
interface KeyProps extends Pick<PanelProps, "lit" | "held" | "onPress"> {
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 (
<button
type="button"
className={cx(className, isHeld && styles.held, !isHeld && isLit && styles.lit)}
data-control={id}
aria-label={panelLabel(id)}
onClick={onPress ? () => onPress(id) : undefined}
>
{isLit ? entries[0]!.short : panelLabel(id)}
{entries && entries.length > 1 ? (
<span className={styles.badge}>{entries.length}</span>
) : null}
</button>
);
}
export function Panel({ lit, held, screen, onPress }: PanelProps) {
const key = { lit, held, ...(onPress ? { onPress } : {}) };
return (
<div className={styles.panel}>
<div className={styles.knobs}>
{KNOBS.map((id) => (
<Key key={id} id={id} className={styles.knob} {...key} />
))}
</div>
<div className={styles.screenRow}>
<div className={styles.fxCol}>
{FX_LEFT.map((id) => (
<Key key={id} id={id} className={styles.fx} {...key} />
))}
</div>
{/* The circle sits behind the display and shows only as caps above and
below it — no ring around the rectangle. */}
<div className={styles.screenWrap}>
<div className={styles.circle} aria-hidden="true" />
<div className={styles.lcd}>
<div className={styles.lcdRow}>
<span>{screen.top}</span>
<span>{screen.right}</span>
</div>
<div className={styles.lcdMain}>{screen.main}</div>
<div className={styles.lcdRow}>
{screen.chips.map((chip) => (
<span key={chip} className={styles.chip}>
{chip}
</span>
))}
</div>
</div>
</div>
<div className={styles.fxCol}>
{FX_RIGHT.map((id) => (
<Key key={id} id={id} className={styles.fx} {...key} />
))}
</div>
</div>
<div className={styles.ctl}>
{CONTROL_GROUPS.map((group) => (
<div className={styles.group} key={group.left.join()}>
<div className={styles.col}>
<div className={styles.caption}>
{group.leftCaption ? <span>{group.leftCaption}</span> : null}
<i className={styles.rule} />
</div>
<div className={styles.btnRow}>
{group.left.map((id) => (
<Key key={id} id={id} className={styles.key} {...key} />
))}
</div>
</div>
<div className={styles.col}>
<div className={styles.caption}>
{group.rightCaption ? <span>{group.rightCaption}</span> : null}
<i className={styles.rule} />
</div>
<div className={styles.btnRow}>
{group.right.map((id) => (
<Key key={id} id={id} className={styles.key} {...key} />
))}
{group.rightKnob ? (
<Key id={group.rightKnob} className={styles.valueKnob} {...key} />
) : null}
</div>
</div>
</div>
))}
</div>
<div className={styles.pads}>
{PAD_ROWS.map((row) => (
<div className={styles.padRow} key={row.side}>
{row.pads.map((id) => (
<Key key={id} id={id} className={styles.pad} {...key} />
))}
<Key id={row.side} className={styles.side} {...key} />
</div>
))}
</div>
</div>
);
}