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:
17
CLAUDE.md
17
CLAUDE.md
@@ -179,8 +179,25 @@ src/domain/
|
|||||||
format.ts ids to printed words, and the "SHIFT + PAD 1" lines
|
format.ts ids to printed words, and the "SHIFT + PAD 1" lines
|
||||||
search.ts the Find tab's matcher
|
search.ts the Find tab's matcher
|
||||||
storage.ts the persisted blob, backup parsing, defaults
|
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:
|
**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
|
`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
|
mean the boxes already on the phone are picked up by the React app rather than
|
||||||
|
|||||||
38
src/App.tsx
38
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
|
* Angular: useState is roughly a component field plus change detection. Setting
|
||||||
* it re-runs this function, so `entries` and `counts` below are recomputed each
|
* it re-runs this function, so derived values below are recomputed each render;
|
||||||
* render — cheap here, and `useMemo` is the explicit opt-out if it stops being.
|
* useMemo is the explicit opt-out when that stops being cheap.
|
||||||
*/
|
*/
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ENTRIES } from "./domain/entries";
|
import { ENTRIES } from "./domain/entries";
|
||||||
import { CTX } from "./domain/labels";
|
|
||||||
import { controlName } from "./domain/format";
|
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 type { ContextCode } from "./domain/types";
|
||||||
|
import { Panel } from "./ui/Panel";
|
||||||
import styles from "./App.module.css";
|
import styles from "./App.module.css";
|
||||||
|
|
||||||
const TABS = ["Explore", "Drill", "Find", "Rules"] as const;
|
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() {
|
function Caret() {
|
||||||
return (
|
return (
|
||||||
<svg width="9" height="6" viewBox="0 0 9 6" aria-hidden="true">
|
<svg width="9" height="6" viewBox="0 0 9 6" aria-hidden="true">
|
||||||
@@ -38,6 +39,14 @@ export function App() {
|
|||||||
// Changing mode can strip the held layer out from under us.
|
// Changing mode can strip the held layer out from under us.
|
||||||
const activeLayer = counts.has(layer) ? layer : "";
|
const activeLayer = counts.has(layer) ? layer : "";
|
||||||
|
|
||||||
|
const onLayer = useMemo(
|
||||||
|
() => entriesForLayer(ENTRIES, ctx, activeLayer),
|
||||||
|
[ctx, activeLayer],
|
||||||
|
);
|
||||||
|
const lit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
|
||||||
|
|
||||||
|
const held = activeLayer === "" ? [] : activeLayer.split("+");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<div className={styles.strip}>
|
<div className={styles.strip}>
|
||||||
@@ -60,15 +69,20 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<b>{layerLabel(activeLayer)}</b>
|
<b>{layerLabel(activeLayer)}</b>
|
||||||
<span className={styles.count}>{counts.get(activeLayer) ?? 0}</span>
|
<span className={styles.count}>{onLayer.length}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.panel}>
|
<Panel
|
||||||
<p className={styles.placeholder}>
|
lit={lit}
|
||||||
{counts.get(activeLayer) ?? 0} functions on this layer
|
held={held}
|
||||||
</p>
|
screen={{
|
||||||
</div>
|
top: `MODE:${ctx}`,
|
||||||
|
right: activeLayer === "" ? "LAYER:—" : layerLabel(activeLayer),
|
||||||
|
main: `${onLayer.length} function${onLayer.length === 1 ? "" : "s"}`,
|
||||||
|
chips: onLayer.slice(0, 3).map((e) => e.short),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={styles.info} />
|
<div className={styles.info} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** Turning ids into the words printed on the panel and in combo lines. */
|
/** 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";
|
import type { ContextCode, ControlId, Entry, GroupId, RawEntry } from "./types";
|
||||||
|
|
||||||
const GROUP_IDS: ReadonlySet<string> = new Set(Object.keys(GROUPS));
|
const GROUP_IDS: ReadonlySet<string> = new Set(Object.keys(GROUPS));
|
||||||
@@ -9,6 +9,11 @@ export function controlName(id: ControlId): string {
|
|||||||
return L[id] ?? id;
|
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
|
* Name of whatever an entry targets: one control, a group ("PAD 1–16"), or an
|
||||||
* explicit list joined with a slash.
|
* explicit list joined with a slash.
|
||||||
|
|||||||
253
src/ui/Panel.module.css
Normal file
253
src/ui/Panel.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
144
src/ui/Panel.tsx
Normal file
144
src/ui/Panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/ui/cx.ts
Normal file
9
src/ui/cx.ts
Normal file
@@ -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(" ");
|
||||||
64
src/ui/panelLayout.test.ts
Normal file
64
src/ui/panelLayout.test.ts
Normal file
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/ui/panelLayout.ts
Normal file
71
src/ui/panelLayout.ts
Normal file
@@ -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]),
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user