The controls whose chrome was hardcoded dark now adapt to a light base —
previously they rendered as dark chips/holes on light themes. (Flat knobs and
faders already adapted: their face is the `face` token.)
- `ledWell` theme token — the recessed screen behind LED segments (LEDKnob,
LEDFader, Meter) and the default well of SegmentDisplay / AlphaDisplay now
comes from the theme. Dark base keeps #0b0d0e; light base is `transparent`,
so LED controls and readouts render as clean flat rings / bars / digits on
the light panel. faceColor / background props still override.
- `scheme` theme token ('dark' | 'light', set by base) + a shared
panelButtonChrome() helper drive the raised gradient/border/shadow of
PushButton (and Button), TransportButton and SegmentSwitch — light raised
buttons on light themes instead of dark chips; unlit LED pips flip to a
faint dark so they still read.
- Docs: a "Light theme" gallery card showing the panel family under
base="light"; ledWell/scheme documented; install URLs bumped to v1.1.4.
Library + docs typecheck and build; 46 tests pass.
180 lines
5.2 KiB
TypeScript
180 lines
5.2 KiB
TypeScript
import * as React from 'react';
|
||
import { panelButtonChrome, 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 chrome = panelButtonChrome(theme.scheme, active);
|
||
|
||
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: chrome.border,
|
||
background: chrome.background,
|
||
boxShadow: `${chrome.boxShadow}${
|
||
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>
|
||
);
|
||
},
|
||
);
|