Readout legibility (the #1 ask): - bold own-cell decimal point + colon in seven/fourteen-seg (not hairlines) - weight prop thickens strokes; gap controls inter-cell kerning Display colors: - SegmentDisplay defaults to theme.ledGreen, AlphaDisplay to theme.ledAmber - valueColor fn + value-space zones on SegmentDisplay (self-coloring readouts) - charset: colon in both, $ and % in 14-seg; disabled + data-disabled Knob captions: sublabel (second line) + labelSize (independent of dial size). New components: - ToggleSwitch (rocker/slider boolean, role=switch) - Gauge (read-only radial arc meter, round sibling of Meter) - TransportButton (play/stop/record/pause/panic, record blinks) - LampRow (console-print lamp bank), LabeledField, Rack (panel chrome) Responsiveness: fill on Meter + Fader (stretch to container). Niceties: PushButton leadingIcon; theme ledGreen/ledAmber/panel tokens. 54 -> 63 exports. 38 unit tests. Browser-verified.
185 lines
5.4 KiB
TypeScript
185 lines
5.4 KiB
TypeScript
import * as React from 'react';
|
||
import { useKnobTheme } from '../core/theme';
|
||
|
||
export type TransportKind =
|
||
| 'play'
|
||
| 'pause'
|
||
| 'stop'
|
||
| 'record'
|
||
| 'rewind'
|
||
| 'forward'
|
||
| 'panic';
|
||
|
||
export interface TransportButtonProps {
|
||
kind: TransportKind;
|
||
/** Engaged/lit state (playing, armed, recording). Reflects app state. */
|
||
active?: boolean;
|
||
onClick?: () => void;
|
||
/** Pulse the glyph while active (auto-on for `record`). Reduced-motion aware. */
|
||
blink?: boolean;
|
||
/** Override the glyph/LED color. Defaults per kind (play green, record red…). */
|
||
color?: string;
|
||
/** Button size in px (square). Default: 38. */
|
||
size?: number;
|
||
disabled?: boolean;
|
||
className?: string;
|
||
style?: React.CSSProperties;
|
||
'aria-label'?: string;
|
||
}
|
||
|
||
const LABEL: Record<TransportKind, string> = {
|
||
play: 'Play',
|
||
pause: 'Pause',
|
||
stop: 'Stop',
|
||
record: 'Record',
|
||
rewind: 'Rewind',
|
||
forward: 'Forward',
|
||
panic: 'Panic — all notes off',
|
||
};
|
||
|
||
/** Glyph in a 24×24 box, filled with `currentColor`. */
|
||
const Glyph: React.FC<{ kind: TransportKind }> = ({ kind }) => {
|
||
switch (kind) {
|
||
case 'play':
|
||
return <path d="M8 5.5 L19 12 L8 18.5 Z" />;
|
||
case 'pause':
|
||
return (
|
||
<>
|
||
<rect x="7" y="5.5" width="3.6" height="13" rx="1" />
|
||
<rect x="13.4" y="5.5" width="3.6" height="13" rx="1" />
|
||
</>
|
||
);
|
||
case 'stop':
|
||
return <rect x="6.5" y="6.5" width="11" height="11" rx="1.5" />;
|
||
case 'record':
|
||
return <circle cx="12" cy="12" r="5.2" />;
|
||
case 'rewind':
|
||
return (
|
||
<>
|
||
<path d="M11.5 6 L11.5 18 L4 12 Z" />
|
||
<path d="M20 6 L20 18 L12.5 12 Z" />
|
||
</>
|
||
);
|
||
case 'forward':
|
||
return (
|
||
<>
|
||
<path d="M4 6 L11.5 12 L4 18 Z" />
|
||
<path d="M12.5 6 L20 12 L12.5 18 Z" />
|
||
</>
|
||
);
|
||
case 'panic':
|
||
return (
|
||
<>
|
||
<rect x="10.6" y="5" width="2.8" height="8.4" rx="1.4" />
|
||
<circle cx="12" cy="17.4" r="1.7" />
|
||
</>
|
||
);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* A record / play / stop / pause / panic transport button, styled to sit
|
||
* alongside dreamknob controls. `active` lights it; `record` blinks while
|
||
* armed. Reduced-motion aware.
|
||
*/
|
||
export const TransportButton = React.forwardRef<HTMLButtonElement, TransportButtonProps>(
|
||
function TransportButton(
|
||
{ kind, active = false, onClick, blink, color, size = 38, disabled = false, className, style, ...aria },
|
||
ref,
|
||
) {
|
||
const theme = useKnobTheme();
|
||
const [focusVisible, setFocusVisible] = React.useState(false);
|
||
const glyphRef = React.useRef<SVGSVGElement>(null);
|
||
|
||
const accent =
|
||
color ??
|
||
(kind === 'record' || kind === 'panic'
|
||
? theme.zoneHot
|
||
: kind === 'play'
|
||
? theme.zoneGood
|
||
: kind === 'pause'
|
||
? theme.zoneWarn
|
||
: theme.text);
|
||
const lit = active ? accent : theme.label;
|
||
const shouldBlink = (blink ?? kind === 'record') && active;
|
||
|
||
React.useEffect(() => {
|
||
const el = glyphRef.current;
|
||
if (!el || !shouldBlink) return;
|
||
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||
return;
|
||
const anim = el.animate([{ opacity: 1 }, { opacity: 0.3 }], {
|
||
duration: 560,
|
||
iterations: Infinity,
|
||
direction: 'alternate',
|
||
easing: 'ease-in-out',
|
||
});
|
||
return () => anim.cancel();
|
||
}, [shouldBlink]);
|
||
|
||
return (
|
||
<button
|
||
ref={ref}
|
||
type="button"
|
||
aria-label={aria['aria-label'] ?? LABEL[kind]}
|
||
aria-pressed={active}
|
||
disabled={disabled}
|
||
data-active={active ? '' : undefined}
|
||
data-kind={kind}
|
||
data-focus-visible={focusVisible ? '' : undefined}
|
||
className={className}
|
||
onClick={onClick}
|
||
onFocus={e => {
|
||
try {
|
||
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||
} catch {
|
||
setFocusVisible(true);
|
||
}
|
||
}}
|
||
onBlur={() => setFocusVisible(false)}
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
width: size,
|
||
height: size,
|
||
padding: 0,
|
||
borderRadius: 8,
|
||
border: '1px solid rgba(0,0,0,0.6)',
|
||
background: active
|
||
? 'linear-gradient(180deg, #17181d, #232429)'
|
||
: 'linear-gradient(180deg, #35363d, #1d1e24)',
|
||
boxShadow: `${
|
||
active
|
||
? 'inset 0 2px 5px rgba(0,0,0,0.6)'
|
||
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)'
|
||
}${active ? `, 0 0 ${size * 0.28}px ${accent}55` : ''}${
|
||
focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''
|
||
}`,
|
||
outline: 'none',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
opacity: disabled ? 0.45 : 1,
|
||
touchAction: 'manipulation',
|
||
transition: 'background 80ms, box-shadow 80ms',
|
||
...style,
|
||
}}
|
||
>
|
||
<svg
|
||
ref={glyphRef}
|
||
width={size * 0.55}
|
||
height={size * 0.55}
|
||
viewBox="0 0 24 24"
|
||
fill={lit}
|
||
style={{
|
||
display: 'block',
|
||
filter: active ? `drop-shadow(0 0 ${size * 0.12}px ${accent})` : undefined,
|
||
transition: 'fill 80ms',
|
||
}}
|
||
aria-hidden="true"
|
||
>
|
||
<Glyph kind={kind} />
|
||
</svg>
|
||
</button>
|
||
);
|
||
},
|
||
);
|