DreamKnob/packages/dreamknob/src/skins/TransportButton.tsx
Dreamodus 8b462dfb83 v1.1.4: light-theme support (LED wells, panel buttons, readouts)
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.
2026-07-14 14:21:57 -07:00

180 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
},
);