Four things reported on the device, all real. Tapping a modifier did nothing. setLayer was only reachable from a search result or the layer sheet, so the reference's latch was simply never ported. It is now, with the semantics intact rather than reduced to a toggle: hold to peek and it lifts away, tap to lock and it stays, tap again to release, and reading a lit key while holding a modifier spends that modifier so lifting drops it. Knobs and pads never latch, which is what NOLATCH is for. held is now the source of truth and the layer key is derived from it, as in the reference. That needed per-control release events. Panel knew which control each pointer held and threw it away; it now reports onRelease before the gesture-wide callback, because the latch decides hold-versus-tap from how long one key was down. Haptics and the hardware back button were missing entirely - Phase 1 did both in native.js and the port had no equivalent. native.ts adds them, no-ops in a browser so the dev server is unaffected. Back owns the whole stack: sheet, then Explore, then exit. Grade haptics fire from the transition into answered, not from the four dispatch sites. Grading happens in the reducer, so deciding correctness again at each call site would be a second source of truth free to drift. PINGPONG overflowed its key. Lit keys show the entry's short label, which comes from the data and can be one unbreakable word; the soft hyphens only cover printed labels. Keys now break anywhere and clip. 141 tests, typecheck clean, installed and running on the device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
258 lines
8.6 KiB
TypeScript
258 lines
8.6 KiB
TypeScript
/**
|
|
* The SP-404MK2 panel. Presentational: it is told what is lit, and it reports
|
|
* presses. It owns pointer bookkeeping only because it owns the DOM — what a
|
|
* chord *means* stays in App.
|
|
*
|
|
* Multitouch matters here: answering a drill card means holding two or three
|
|
* controls at once. Pointers are tracked per pointerId, and the gesture ends
|
|
* when the last finger lifts, which is when a held chord is graded.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { panelLabel } from "../domain/format";
|
|
import type { ControlId, Entry } from "../domain/types";
|
|
import { cx } from "./cx";
|
|
import { tapFeedback } from "./native";
|
|
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;
|
|
readonly chips: readonly string[];
|
|
}
|
|
|
|
export interface PanelProps {
|
|
readonly lit: ReadonlyMap<ControlId, Entry[]>;
|
|
readonly held: readonly ControlId[];
|
|
readonly selected?: ControlId | null;
|
|
readonly screen: ScreenText;
|
|
/** A control went down. */
|
|
readonly onPress?: ((id: ControlId) => void) | undefined;
|
|
/** That control's finger lifted. Explore's latch needs the timing per key. */
|
|
readonly onRelease?: ((id: ControlId) => void) | undefined;
|
|
/** The last finger lifted; every control touched during the gesture. */
|
|
readonly onGestureEnd?: ((ids: ControlId[]) => void) | undefined;
|
|
}
|
|
|
|
interface KeyProps {
|
|
readonly id: ControlId;
|
|
readonly className: string | undefined;
|
|
readonly lit: ReadonlyMap<ControlId, Entry[]>;
|
|
readonly held: readonly ControlId[];
|
|
readonly selected: ControlId | null;
|
|
readonly down: ReadonlySet<ControlId>;
|
|
readonly onKey: (id: ControlId) => void;
|
|
}
|
|
|
|
/** Pads print their number, not "PAD 7" — until a layer lights them up. */
|
|
const padNumber = (id: ControlId): string | undefined => /^p(\d+)$/.exec(id)?.[1];
|
|
|
|
function Key({ id, className, lit, held, selected, down, onKey }: KeyProps) {
|
|
const entries = lit.get(id);
|
|
const isHeld = held.includes(id);
|
|
const isLit = entries !== undefined && entries.length > 0;
|
|
const isSel = isLit && id === selected;
|
|
const number = padNumber(id);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cx(
|
|
className,
|
|
down.has(id) && styles.down,
|
|
isHeld && styles.held,
|
|
!isHeld && isSel && styles.sel,
|
|
!isHeld && isLit && !isSel && styles.lit,
|
|
)}
|
|
data-control={id}
|
|
aria-label={panelLabel(id)}
|
|
aria-pressed={isHeld || isSel}
|
|
// Pointer events drive presses; this is the keyboard equivalent.
|
|
onKeyDown={(e) => {
|
|
if (e.key !== "Enter" && e.key !== " ") return;
|
|
e.preventDefault();
|
|
onKey(id);
|
|
}}
|
|
>
|
|
{isLit ? (
|
|
entries[0]!.short
|
|
) : number ? (
|
|
<span className={styles.num}>{number}</span>
|
|
) : (
|
|
panelLabel(id)
|
|
)}
|
|
{entries && entries.length > 1 ? (
|
|
<span className={styles.badge}>{entries.length}</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function Panel({
|
|
lit,
|
|
held,
|
|
selected,
|
|
screen,
|
|
onPress,
|
|
onRelease,
|
|
onGestureEnd,
|
|
}: PanelProps) {
|
|
/** pointerId to the control it is holding. */
|
|
const active = useRef(new Map<number, ControlId>());
|
|
/** Everything touched since the first finger went down. */
|
|
const gesture = useRef(new Set<ControlId>());
|
|
const [down, setDown] = useState<ReadonlySet<ControlId>>(new Set());
|
|
|
|
// Kept in refs so the window listeners below can be attached once and still
|
|
// call the current callbacks.
|
|
const pressRef = useRef(onPress);
|
|
const releaseRef = useRef(onRelease);
|
|
const endRef = useRef(onGestureEnd);
|
|
pressRef.current = onPress;
|
|
releaseRef.current = onRelease;
|
|
endRef.current = onGestureEnd;
|
|
|
|
useEffect(() => {
|
|
// Fingers often lift outside the key they went down on, so releases are
|
|
// heard on the window rather than the button.
|
|
function release(e: PointerEvent) {
|
|
const id = active.current.get(e.pointerId);
|
|
if (id === undefined) return;
|
|
active.current.delete(e.pointerId);
|
|
setDown(new Set(active.current.values()));
|
|
// Per-control, and before the gesture-wide callback: Explore's latch
|
|
// decides hold-versus-tap from how long this one key was down.
|
|
releaseRef.current?.(id);
|
|
if (active.current.size > 0) return;
|
|
|
|
const ids = [...gesture.current];
|
|
gesture.current.clear();
|
|
if (ids.length > 0) endRef.current?.(ids);
|
|
}
|
|
window.addEventListener("pointerup", release);
|
|
window.addEventListener("pointercancel", release);
|
|
return () => {
|
|
window.removeEventListener("pointerup", release);
|
|
window.removeEventListener("pointercancel", release);
|
|
};
|
|
}, []);
|
|
|
|
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-control]");
|
|
if (!el) return;
|
|
const id = el.dataset["control"];
|
|
if (!id) return;
|
|
|
|
// Stops the browser treating a second finger as a pinch or a pan.
|
|
e.preventDefault();
|
|
active.current.set(e.pointerId, id);
|
|
gesture.current.add(id);
|
|
setDown(new Set(active.current.values()));
|
|
// Every key gets a tick, including the second and third finger of a chord.
|
|
tapFeedback();
|
|
pressRef.current?.(id);
|
|
}, []);
|
|
|
|
/** Keyboard: a full press, release and gesture in one go, so taps latch. */
|
|
const onKey = useCallback((id: ControlId) => {
|
|
pressRef.current?.(id);
|
|
releaseRef.current?.(id);
|
|
endRef.current?.([id]);
|
|
}, []);
|
|
|
|
const key = { lit, held, selected: selected ?? null, down, onKey };
|
|
|
|
return (
|
|
<div
|
|
className={styles.panel}
|
|
onPointerDown={onPointerDown}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
>
|
|
<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>
|
|
);
|
|
}
|