v1.1.1: second integration-feedback release

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.
This commit is contained in:
Dreamodus 2026-07-13 19:20:23 -07:00
parent d8b7b55505
commit 5b97091902
32 changed files with 1535 additions and 133 deletions

View file

@ -0,0 +1,185 @@
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>
);
},
);