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:
parent
d8b7b55505
commit
5b97091902
32 changed files with 1535 additions and 133 deletions
185
packages/dreamknob/src/skins/TransportButton.tsx
Normal file
185
packages/dreamknob/src/skins/TransportButton.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue