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

@ -34,6 +34,12 @@ export interface KnobTheme {
zoneWarn: string;
/** Meter/LED zone semantics: too hot / clipping. */
zoneHot: string;
/** Default lit color for green seven-segment readouts (`SegmentDisplay`). */
ledGreen: string;
/** Default lit color for amber alphanumeric readouts (`AlphaDisplay`). */
ledAmber: string;
/** Panel/rack chrome background (`Rack`, panel containers). */
panel: string;
fontMono: string;
fontUI: string;
}
@ -48,6 +54,9 @@ export const darkTheme: KnobTheme = {
zoneGood: '#3df2ad',
zoneWarn: '#ffd23e',
zoneHot: '#ff4d6b',
ledGreen: '#3df2ad',
ledAmber: '#ffb84d',
panel: 'rgba(255,255,255,0.03)',
fontMono: MONO_FONT,
fontUI: UI_FONT,
};
@ -62,6 +71,9 @@ export const lightTheme: KnobTheme = {
zoneGood: '#0fa571',
zoneWarn: '#c99000',
zoneHot: '#d92546',
ledGreen: '#0fa571',
ledAmber: '#b26a00',
panel: 'rgba(0,0,0,0.04)',
fontMono: MONO_FONT,
fontUI: UI_FONT,
};

View file

@ -1,4 +1,5 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
// Fourteen-segment geometry in a local 10 x 18 digit cell.
// Outer ring: a top, b top-right, c bottom-right, d bottom, e bottom-left,
@ -7,38 +8,38 @@ import * as React from 'react';
const W = 10;
const H = 18;
const GAP = 3.4;
const HALF = 1.05;
const DEFAULT_GAP = 3.4;
const BASE_HALF = 1.05;
const INSET = 0.45;
const hSeg = (y: number, x1: number, x2: number): string => {
const hSeg = (y: number, x1: number, x2: number, half: number): string => {
const a = x1 + INSET;
const b = x2 - INSET;
return `${a},${y} ${a + HALF},${y - HALF} ${b - HALF},${y - HALF} ${b},${y} ${b - HALF},${y + HALF} ${a + HALF},${y + HALF}`;
return `${a},${y} ${a + half},${y - half} ${b - half},${y - half} ${b},${y} ${b - half},${y + half} ${a + half},${y + half}`;
};
const vSeg = (x: number, y1: number, y2: number): string => {
const vSeg = (x: number, y1: number, y2: number, half: number): string => {
const a = y1 + INSET;
const b = y2 - INSET;
return `${x},${a} ${x + HALF},${a + HALF} ${x + HALF},${b - HALF} ${x},${b} ${x - HALF},${b - HALF} ${x - HALF},${a + HALF}`;
return `${x},${a} ${x + half},${a + half} ${x + half},${b - half} ${x},${b} ${x - half},${b - half} ${x - half},${a + half}`;
};
type Seg14 =
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
| 'g1' | 'g2' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm';
const POLY: Partial<Record<Seg14, string>> = {
a: hSeg(1, 1, 9),
d: hSeg(17, 1, 9),
g1: hSeg(9, 1, 5),
g2: hSeg(9, 5, 9),
f: vSeg(1, 1, 9),
b: vSeg(9, 1, 9),
e: vSeg(1, 9, 17),
c: vSeg(9, 9, 17),
i: vSeg(5, 1, 9),
l: vSeg(5, 9, 17),
};
const buildPoly = (half: number): Partial<Record<Seg14, string>> => ({
a: hSeg(1, 1, 9, half),
d: hSeg(17, 1, 9, half),
g1: hSeg(9, 1, 5, half),
g2: hSeg(9, 5, 9, half),
f: vSeg(1, 1, 9, half),
b: vSeg(9, 1, 9, half),
e: vSeg(1, 9, 17, half),
c: vSeg(9, 9, 17, half),
i: vSeg(5, 1, 9, half),
l: vSeg(5, 9, 17, half),
});
/** Diagonals render as thick lines: [x1, y1, x2, y2]. */
const DIAG: Partial<Record<Seg14, [number, number, number, number]>> = {
@ -85,6 +86,8 @@ const CHARS: Record<string, Seg14[]> = {
X: ['h', 'j', 'k', 'm'],
Y: ['h', 'j', 'l'],
Z: ['a', 'd', 'j', 'k'],
// '$' is an S with the center verticals lit through it.
$: ['a', 'c', 'd', 'f', 'g1', 'g2', 'i', 'l'],
'-': ['g1', 'g2'],
'_': ['d'],
'=': ['g1', 'g2', 'd'],
@ -96,17 +99,25 @@ const CHARS: Record<string, Seg14[]> = {
' ': [],
};
const ALL_SEGMENTS = Object.keys({ ...POLY, ...DIAG }) as Seg14[];
const ALL_SEGMENTS = Object.keys({ ...buildPoly(BASE_HALF), ...DIAG }) as Seg14[];
export interface AlphaDisplayProps {
/** Text: AZ, 09, space, - _ = + * / \ ? and '.' (attaches to the previous cell). */
/** Text: AZ, 09, space, - _ = + * / \ ? $ : % and '.' (attaches to the previous cell). */
value: string;
/** Fixed number of character cells; the text is padded/cropped to fit. */
chars?: number;
align?: 'left' | 'right';
/** Character height in px. Default: 22. */
height?: number;
/** Lit color. Defaults to the theme's `ledAmber`. */
color?: string;
/**
* Segment thickness multiplier thickens strokes so the display holds up at
* small heights. Default: 1.
*/
weight?: number;
/** Inter-cell spacing. Default: 3.4. */
gap?: number;
/** Panel background. Set to 'none' to disable. */
background?: string;
/** Opacity of unlit segments. Default: 0.08. */
@ -116,6 +127,8 @@ export interface AlphaDisplayProps {
/** LED glow strength (0 disables). Default: 1.4. */
glow?: number;
padding?: number;
/** Dim the display uniformly (matches disabled controls). */
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
@ -129,20 +142,29 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
chars,
align = 'left',
height = 22,
color = '#ffb84d',
color,
weight = 1,
gap = DEFAULT_GAP,
background = '#0a0d0c',
ghostOpacity = 0.08,
skew = 6,
glow = 1.4,
padding = 6,
disabled = false,
className,
style,
}) => {
const theme = useKnobTheme();
const lit = color ?? theme.ledAmber;
const filterId = React.useId();
const half = BASE_HALF * weight;
const POLY = buildPoly(half);
const dot = half * 1.35;
const diagWidth = 1.5 * weight;
let text = value.toUpperCase();
if (chars !== undefined) {
const cellCount = text.replace(/\./g, '').length;
const cellCount = text.replace(/[.]/g, '').length;
if (cellCount < chars) {
const pad = ' '.repeat(chars - cellCount);
text = align === 'right' ? pad + text : text + pad;
@ -164,19 +186,43 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
for (const ch of text) {
if (ch === '.') {
cells.push(
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={H - 1.2} r={1.15} fill={color} />,
<rect key={key++} x={x - dot} y={H - dot * 2} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />,
);
x += dot * 2 + Math.min(gap * 0.6, 2.6);
continue;
}
const lit = new Set(CHARS[ch] ?? []);
if (ch === ':') {
const cx = x + dot;
cells.push(
<g key={key++}>
<rect x={cx - dot} y={H * 0.3 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />
<rect x={cx - dot} y={H * 0.72 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />
</g>,
);
x += dot * 2 + gap;
continue;
}
if (ch === '%') {
// Slash with a dot at each end — the percent idiom.
cells.push(
<g key={key++} transform={`translate(${x} 0)`}>
<line x1={2} y1={16} x2={8} y2={2} stroke={lit} strokeWidth={diagWidth} strokeLinecap="butt" />
<circle cx={2.8} cy={4} r={dot * 1.05} fill={lit} />
<circle cx={7.2} cy={14} r={dot * 1.05} fill={lit} />
</g>,
);
x += W + gap;
continue;
}
const on = new Set(CHARS[ch] ?? []);
const parts: React.ReactNode[] = [];
for (const seg of ALL_SEGMENTS) {
const on = lit.has(seg);
if (!on && ghostOpacity <= 0) continue;
const opacity = on ? 1 : ghostOpacity;
const isOn = on.has(seg);
if (!isOn && ghostOpacity <= 0) continue;
const opacity = isOn ? 1 : ghostOpacity;
const poly = POLY[seg];
if (poly) {
parts.push(<polygon key={seg} points={poly} fill={color} opacity={opacity} />);
parts.push(<polygon key={seg} points={poly} fill={lit} opacity={opacity} />);
} else {
const [x1, y1, x2, y2] = DIAG[seg]!;
parts.push(
@ -186,8 +232,8 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
y1={y1}
x2={x2}
y2={y2}
stroke={color}
strokeWidth={1.5}
stroke={lit}
strokeWidth={diagWidth}
strokeLinecap="butt"
opacity={opacity}
/>,
@ -199,10 +245,10 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
{parts}
</g>,
);
x += W + GAP;
x += W + gap;
}
const contentW = Math.max(x - GAP, 0);
const contentW = Math.max(x - gap, 0);
const scale = height / H;
const w = contentW * scale + padding * 2 + height * 0.15;
const h = height + padding * 2;
@ -213,7 +259,8 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
height={h}
viewBox={`0 0 ${w} ${h}`}
className={className}
style={style}
style={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
data-disabled={disabled ? '' : undefined}
role="img"
aria-label={value}
>

View file

@ -1,14 +1,33 @@
import * as React from 'react';
import { formatForDisplay, renderSegmentText } from './segments';
import { useKnobTheme } from '../core/theme';
import { DisplayZone, formatForDisplay, renderSegmentText, zoneColorForValue } from './segments';
export interface SegmentDisplayProps {
value: number | string;
/** Number of digit cells (decimal points don't count). Default: 4. */
/** Number of digit cells (points/colons don't count). Default: 4. */
digits?: number;
decimals?: number;
/** Digit height in px. Default: 28. */
/** Digit height in px. Default: 28. Below ~11px, bump `weight` to stay legible. */
height?: number;
/** Lit color. Defaults to the theme's `ledGreen`. `valueColor`/`zones` win over it. */
color?: string;
/**
* Color the readout from its numeric value e.g. green/amber/red by load.
* Takes precedence over `color`/`zones`. Ignored for string values.
*/
valueColor?: (value: number) => string;
/**
* Value-space color zones (thresholds are values, not normalized), e.g.
* `[{upTo: 75, color: green}, {upTo: 90, color: amber}, {upTo: Infinity, color: red}]`.
*/
zones?: readonly DisplayZone[];
/**
* Segment thickness multiplier thickens strokes so the display holds up at
* small heights. Default: 1.
*/
weight?: number;
/** Inter-cell spacing. Default: 3.2 — raise it to space crowded readouts. */
gap?: number;
/** Panel background. Set to 'none' to disable. */
background?: string;
/** Opacity of unlit segments. Default: 0.09. */
@ -18,6 +37,8 @@ export interface SegmentDisplayProps {
/** LED glow strength (0 disables). Default: 1.6. */
glow?: number;
padding?: number;
/** Dim the display uniformly (matches disabled controls). */
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
@ -31,22 +52,37 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
digits = 4,
decimals = 0,
height = 28,
color = '#3df2ad',
color,
valueColor,
zones,
weight = 1,
gap,
background = '#0a0d0c',
ghostOpacity = 0.09,
skew = 6,
glow = 1.6,
padding = 6,
disabled = false,
className,
style,
}) => {
const theme = useKnobTheme();
const filterId = React.useId();
const numeric = typeof value === 'number' ? value : NaN;
const resolved =
(valueColor && !Number.isNaN(numeric) ? valueColor(numeric) : undefined) ??
(zones && !Number.isNaN(numeric) ? zoneColorForValue(zones, numeric) : undefined) ??
color ??
theme.ledGreen;
const text =
typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value;
const { nodes, width, height: cellH } = renderSegmentText(text, {
color,
color: resolved,
ghostOpacity,
skew,
weight,
gap,
});
const scale = height / cellH;
@ -59,7 +95,8 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
height={h}
viewBox={`0 0 ${w} ${h}`}
className={className}
style={style}
style={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
data-disabled={disabled ? '' : undefined}
role="img"
aria-label={typeof value === 'number' ? String(value) : value}
>

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { formatForDisplay, zoneColorForValue } from './segments';
describe('formatForDisplay', () => {
it('right-aligns within the digit width', () => {
// 2 digits (3,5) fill a 2-cell width — the point is extra, no leading pad.
expect(formatForDisplay(3.5, 2, 1)).toBe('3.5');
expect(formatForDisplay(42, 3, 0)).toBe(' 42');
});
it('overflows to dashes when it does not fit', () => {
expect(formatForDisplay(12345, 3, 0)).toBe('---');
});
it('does not count the decimal point toward the digit budget', () => {
// 3 digits (1,2,3) + a point still fits digits=3.
expect(formatForDisplay(12.3, 3, 1)).toBe('12.3');
});
});
describe('zoneColorForValue', () => {
const zones = [
{ upTo: 74, color: 'green' },
{ upTo: 89, color: 'amber' },
{ upTo: Infinity, color: 'red' },
];
it('picks the zone whose upper bound the value falls under', () => {
expect(zoneColorForValue(zones, 40)).toBe('green');
expect(zoneColorForValue(zones, 80)).toBe('amber');
expect(zoneColorForValue(zones, 99)).toBe('red');
});
it('is inclusive at the boundary', () => {
expect(zoneColorForValue(zones, 74)).toBe('green');
expect(zoneColorForValue(zones, 89)).toBe('amber');
});
it('sorts unordered zones before resolving', () => {
const unordered = [
{ upTo: Infinity, color: 'red' },
{ upTo: 74, color: 'green' },
];
expect(zoneColorForValue(unordered, 10)).toBe('green');
});
});

View file

@ -6,7 +6,9 @@ import * as React from 'react';
const DIGIT_W = 10;
const DIGIT_H = 18;
const GAP = 3.2; // spacing between digit cells (leaves room for decimal points)
const DEFAULT_GAP = 3.2; // spacing between digit cells (leaves room for points)
const BASE_HALF = 1.1; // half segment thickness at weight 1
const INSET = 0.45; // gap between adjacent segments
type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g';
@ -57,30 +59,30 @@ const CHAR_SEGMENTS: Record<string, SegmentKey[]> = {
const segmentsFor = (ch: string): SegmentKey[] =>
CHAR_SEGMENTS[ch] ?? CHAR_SEGMENTS[ch.toUpperCase()] ?? CHAR_SEGMENTS[ch.toLowerCase()] ?? [];
const H_HALF = 1.1; // half thickness
const INSET = 0.45; // gap between adjacent segments
const hSegment = (y: number): string => {
const hSegment = (y: number, half: number): string => {
const x1 = 1 + INSET;
const x2 = DIGIT_W - 1 - INSET;
return `${x1},${y} ${x1 + H_HALF},${y - H_HALF} ${x2 - H_HALF},${y - H_HALF} ${x2},${y} ${x2 - H_HALF},${y + H_HALF} ${x1 + H_HALF},${y + H_HALF}`;
return `${x1},${y} ${x1 + half},${y - half} ${x2 - half},${y - half} ${x2},${y} ${x2 - half},${y + half} ${x1 + half},${y + half}`;
};
const vSegment = (x: number, y1: number, y2: number): string => {
const vSegment = (x: number, y1: number, y2: number, half: number): string => {
const a = y1 + INSET;
const b = y2 - INSET;
return `${x},${a} ${x + H_HALF},${a + H_HALF} ${x + H_HALF},${b - H_HALF} ${x},${b} ${x - H_HALF},${b - H_HALF} ${x - H_HALF},${a + H_HALF}`;
return `${x},${a} ${x + half},${a + half} ${x + half},${b - half} ${x},${b} ${x - half},${b - half} ${x - half},${a + half}`;
};
const SEGMENT_POINTS: Record<SegmentKey, string> = {
a: hSegment(1),
g: hSegment(DIGIT_H / 2),
d: hSegment(DIGIT_H - 1),
f: vSegment(1, 1, DIGIT_H / 2),
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2),
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1),
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1),
};
/** Build the seven segment polygons for a given half-thickness (weight). */
const buildSegments = (half: number): Record<SegmentKey, string> => ({
a: hSegment(1, half),
g: hSegment(DIGIT_H / 2, half),
d: hSegment(DIGIT_H - 1, half),
f: vSegment(1, 1, DIGIT_H / 2, half),
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2, half),
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1, half),
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1, half),
});
const SEGMENT_ORDER: SegmentKey[] = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
export interface SegmentRenderOptions {
color: string;
@ -88,40 +90,69 @@ export interface SegmentRenderOptions {
ghostOpacity: number;
/** Negative skew for the classic italic LCD look, in degrees. */
skew: number;
/**
* Segment thickness multiplier thickens the strokes so a display holds
* up at small heights (like a font weight). Default: 1.
*/
weight?: number;
/** Inter-cell spacing override. Default: 3.2. */
gap?: number;
}
/**
* Render `text` (digits, '-', '.', ' ') as seven-segment polygons in local
* coordinates (digit height 18). Returns the nodes plus the total width.
* Render `text` (digits, '-', '.', ':', ' ') as seven-segment polygons in
* local coordinates (digit height 18). The decimal point and colon are drawn
* as bold cells with their own width so they hold up at small sizes rather
* than vanishing between digits. Returns the nodes plus the total width.
*/
export const renderSegmentText = (
text: string,
{ color, ghostOpacity, skew }: SegmentRenderOptions,
{ color, ghostOpacity, skew, weight = 1, gap = DEFAULT_GAP }: SegmentRenderOptions,
): { nodes: React.ReactNode; width: number; height: number } => {
const half = BASE_HALF * weight;
const segPoints = buildSegments(half);
const cells: React.ReactNode[] = [];
let x = 0;
let key = 0;
// Bold point: a rounded cell, not a hairline. Sized to the segment weight.
const dot = half * 1.35;
for (const ch of text) {
if (ch === '.') {
// Decimal point sits in the gap after the previous digit.
cells.push(
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={DIGIT_H - 1.2} r={1.2} fill={color} />,
<rect
key={key++}
x={x - dot}
y={DIGIT_H - dot * 2}
width={dot * 2}
height={dot * 2}
rx={dot * 0.5}
fill={color}
/>,
);
// A little breathing room so the following digit doesn't crowd the point.
x += dot * 2 + Math.min(gap * 0.6, 2.4);
continue;
}
if (ch === ':') {
const cx = x + dot;
cells.push(
<g key={key++}>
<rect x={cx - dot} y={DIGIT_H * 0.3 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={color} />
<rect x={cx - dot} y={DIGIT_H * 0.72 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={color} />
</g>,
);
x += dot * 2 + gap;
continue;
}
const lit = new Set(segmentsFor(ch));
const digit: React.ReactNode[] = [];
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
SEGMENT_ORDER.forEach(seg => {
const on = lit.has(seg);
if (!on && ghostOpacity <= 0) return;
digit.push(
<polygon
key={seg}
points={SEGMENT_POINTS[seg]}
fill={color}
opacity={on ? 1 : ghostOpacity}
/>,
<polygon key={seg} points={segPoints[seg]} fill={color} opacity={on ? 1 : ghostOpacity} />,
);
});
cells.push(
@ -129,18 +160,26 @@ export const renderSegmentText = (
{digit}
</g>,
);
x += DIGIT_W + GAP;
x += DIGIT_W + gap;
}
const width = Math.max(x - GAP, 0);
const nodes = skew ? (
<g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g>
) : (
<>{cells}</>
);
const width = Math.max(x - gap, 0);
const nodes = skew ? <g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g> : <>{cells}</>;
return { nodes, width, height: DIGIT_H };
};
export interface DisplayZone {
/** Upper bound (inclusive) of this zone in value space. */
upTo: number;
color: string;
}
/** Resolve a value to its zone color (value-space thresholds, not normalized). */
export const zoneColorForValue = (zones: readonly DisplayZone[], value: number): string => {
const sorted = [...zones].sort((a, b) => a.upTo - b.upTo);
return (sorted.find(z => value <= z.upTo + 1e-9) ?? sorted[sorted.length - 1]).color;
};
/** Format a number for a fixed-width display, e.g. (3.5, 2, 1) -> " 3.5". */
export const formatForDisplay = (
value: number,
@ -148,7 +187,7 @@ export const formatForDisplay = (
decimals: number,
): string => {
let text = value.toFixed(decimals);
const cellCount = (s: string) => s.replace(/\./g, '').length;
const cellCount = (s: string) => s.replace(/[.:]/g, '').length;
// Doesn't fit: dashes, the hardware overflow convention. All-8s would read
// as a legitimate value.
if (cellCount(text) > digits) text = ''.padStart(digits, '-');

View file

@ -21,6 +21,7 @@ export type {
ChangeSource,
} from './core/types';
export { mixColors, sampleGradient, type GradientStop } from './core/colors';
export type { DisplayZone } from './digital/segments';
export {
clamp,
roundTo,
@ -68,9 +69,21 @@ export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFade
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { Meter, type MeterProps } from './skins/Meter';
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
export { Gauge, type GaugeProps } from './skins/Gauge';
export { PushButton, type PushButtonProps } from './skins/PushButton';
export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow';
export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch';
export { SegmentSwitch, type SegmentSwitchProps } from './skins/SegmentSwitch';
export {
TransportButton,
type TransportButtonProps,
type TransportKind,
} from './skins/TransportButton';
export { ScrubField, type ScrubFieldProps } from './skins/ScrubField';
export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
// Layout
export { LabeledField, type LabeledFieldProps } from './layout/LabeledField';
export { Rack, type RackProps } from './layout/Rack';

View file

@ -0,0 +1,91 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface LabeledFieldProps {
/** Field name printed beside the control. */
label: string;
/** The control (a knob, fader, ScrubField, ToggleSwitch, …). */
children: React.ReactNode;
/** Unit printed after the control, e.g. "px", "dB". */
unit?: string;
/**
* Label column width set the same value on a stack of fields so their
* controls line up. Number = px. Default: auto.
*/
labelWidth?: number | string;
/** 'row' (label · control · unit, default) or 'column' (label above). */
orientation?: 'row' | 'column';
/** Push the control to the right edge of the row. Default: false. */
spread?: boolean;
gap?: number;
labelColor?: string;
className?: string;
style?: React.CSSProperties;
}
/**
* One aligned inspector row: a label, a control and an optional unit. Give a
* stack of fields the same `labelWidth` and their controls line up the
* boilerplate every properties panel rewrites, owned in one place.
*/
export const LabeledField: React.FC<LabeledFieldProps> = ({
label,
children,
unit,
labelWidth,
orientation = 'row',
spread = false,
gap = 10,
labelColor,
className,
style,
}) => {
const theme = useKnobTheme();
const column = orientation === 'column';
const labelEl = (
<span
style={{
flex: 'none',
width: column ? undefined : labelWidth,
fontFamily: theme.fontUI,
fontSize: 11,
letterSpacing: '0.05em',
textTransform: 'uppercase',
color: labelColor ?? theme.label,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
);
return (
<div
className={className}
style={{
display: 'flex',
flexDirection: column ? 'column' : 'row',
alignItems: column ? 'flex-start' : 'center',
gap: column ? 4 : gap,
...style,
}}
>
{labelEl}
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
marginLeft: spread && !column ? 'auto' : undefined,
}}
>
{children}
{unit && (
<span style={{ fontFamily: theme.fontMono, fontSize: 11, color: theme.label, whiteSpace: 'nowrap' }}>
{unit}
</span>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,96 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface RackProps {
/** 'row' lays out strips side by side (a rack); 'column' stacks a strip. Default: 'row'. */
orientation?: 'row' | 'column';
/** Panel caption printed at the top of the chrome. */
title?: string;
/** Spacing between children in px. Default: 14. */
gap?: number;
/** Inner padding in px. Default: 14. */
padding?: number;
/** Draw the panel chrome (border + background). Default: true. */
chrome?: boolean;
/** Cross-axis alignment. Default: 'stretch' for column, 'flex-start' for row. */
align?: React.CSSProperties['alignItems'];
accentColor?: string;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
}
/**
* A rack / channel-strip container that owns the panel chrome and spacing, so a
* console section is declarative: nest `Rack`s (a row of column strips) and the
* borders, background and gaps come for free.
*/
export const Rack: React.FC<RackProps> = ({
orientation = 'row',
title,
gap = 14,
padding = 14,
chrome = true,
align,
accentColor,
className,
style,
children,
}) => {
const theme = useKnobTheme();
const column = orientation === 'column';
return (
<section
className={className}
style={{
display: 'inline-flex',
flexDirection: 'column',
gap: title ? 10 : 0,
padding: chrome ? padding : 0,
borderRadius: 10,
background: chrome ? theme.panel : undefined,
border: chrome ? '1px solid rgba(128,128,128,0.18)' : undefined,
boxShadow: chrome ? 'inset 0 1px 0 rgba(255,255,255,0.04)' : undefined,
...style,
}}
>
{title && (
<header
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontFamily: theme.fontUI,
fontSize: 10.5,
fontWeight: 600,
letterSpacing: '0.14em',
textTransform: 'uppercase',
color: theme.label,
}}
>
<span
aria-hidden="true"
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: accentColor ?? theme.accent ?? theme.zoneGood,
flex: 'none',
}}
/>
{title}
</header>
)}
<div
style={{
display: 'flex',
flexDirection: column ? 'column' : 'row',
alignItems: align ?? (column ? 'stretch' : 'flex-start'),
gap,
}}
>
{children}
</div>
</section>
);
};

View file

@ -66,34 +66,68 @@ export interface KnobLabelProps {
fontFamily?: string;
/** Vertical offset from center; defaults to just below the knob body. */
dy?: number;
/**
* Secondary caption line rendered under the main label a unit or live
* descriptor (`SEED` over `world #7`). Not uppercased or letter-spaced.
*/
sublabel?: React.ReactNode;
/** Sub-caption font size. Default: 0.82× the label size. */
subFontSize?: number;
/** Sub-caption color. Default: the label color at reduced opacity. */
subColor?: string;
textProps?: React.SVGProps<SVGTextElement>;
}
/** Small caption, e.g. the parameter name. */
/** Small caption, e.g. the parameter name — with an optional second line. */
export const KnobLabel: React.FC<KnobLabelProps> = ({
children,
fontSize,
color = 'currentColor',
fontFamily,
dy,
sublabel,
subFontSize,
subColor,
textProps,
}) => {
const { size, center } = useKnobContext();
const fs = fontSize ?? size * 0.12;
const y = center + (dy ?? size * 0.38);
const subFs = subFontSize ?? fs * 0.82;
return (
<text
data-part="label"
x={center}
y={center + (dy ?? size * 0.38)}
textAnchor="middle"
dominantBaseline="central"
fontSize={fontSize ?? size * 0.12}
fill={color}
fontFamily={fontFamily}
letterSpacing="0.08em"
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
{...textProps}
>
{children}
</text>
<>
<text
data-part="label"
x={center}
y={y}
textAnchor="middle"
dominantBaseline="central"
fontSize={fs}
fill={color}
fontFamily={fontFamily}
letterSpacing="0.08em"
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
{...textProps}
>
{children}
</text>
{sublabel != null && sublabel !== '' && (
<text
data-part="sublabel"
x={center}
y={y + fs * 0.72 + subFs * 0.72}
textAnchor="middle"
dominantBaseline="central"
fontSize={subFs}
fill={subColor ?? color}
fontFamily={fontFamily}
opacity={subColor ? 1 : 0.7}
style={{ pointerEvents: 'none' }}
{...textProps}
>
{sublabel}
</text>
)}
</>
);
};

View file

@ -10,6 +10,12 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
focusRing?: string | false;
/** Travel length in px. Default: 160. */
length?: number;
/**
* Stretch along the travel axis to fill the container (horizontal fills
* width, vertical fills height) instead of a fixed `length`. Removes wrapper
* math in resizable dock panels.
*/
fill?: boolean;
/** Cross-axis size in px. Default: 44. */
breadth?: number;
color?: string;
@ -49,6 +55,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
orientation = 'vertical',
focusRing,
length = 160,
fill = false,
breadth = 44,
color: colorProp,
trackColor,
@ -97,6 +104,9 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
const w = vertical ? breadth : length;
const h = vertical ? length : breadth;
const mid = breadth / 2;
// Fill stretches the travel axis; the cross axis stays at `breadth`.
const ctrlW = fill && !vertical ? '100%' : w;
const ctrlH = fill && vertical ? '100%' : h;
// Handle center position along the travel axis.
const travel = length - capMain;
@ -186,14 +196,21 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
style={{
...knob.bind.style,
opacity: undefined, // the outer wrapper owns disabled dimming
width: w,
height: h,
display: 'inline-flex',
width: ctrlW,
height: ctrlH,
display: fill ? 'flex' : 'inline-flex',
borderRadius: 8,
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
}}
>
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
<svg
width={ctrlW}
height={ctrlH}
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio={fill ? 'none' : undefined}
style={{ display: 'block' }}
aria-hidden="true"
>
<defs>
{capColor ? (
// Custom cap: solid base with a neutral shading overlay so any
@ -277,7 +294,8 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
gap: 6,
// Fixed width ('top' mode) so a widening readout never reflows the
// layout; 'end' mode sizes to content with a min-width readout slot.
width: atEnd && !vertical ? undefined : w,
width: fill && !vertical ? '100%' : atEnd && !vertical ? undefined : w,
height: fill && vertical ? '100%' : undefined,
opacity: core.disabled ? 0.45 : undefined,
...style,
}}

View file

@ -32,6 +32,8 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
arcFrom = 'min',
arcThickness,
label,
sublabel,
labelSize,
showValue = true,
unit,
format,
@ -81,7 +83,8 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.42}
fontSize={size * 0.1}
fontSize={labelSize ?? size * 0.1}
sublabel={sublabel}
>
{label}
</KnobLabel>

View file

@ -0,0 +1,179 @@
import * as React from 'react';
import { angleFromNormalized, clamp, describeArc, linearTaper, polarToCartesian } from '../core/math';
import type { Taper } from '../core/math';
import { useKnobTheme } from '../core/theme';
import { SegmentDisplay } from '../digital/SegmentDisplay';
import type { LEDFaderZone } from './LEDFader';
export interface GaugeProps {
value: number;
min?: number;
max?: number;
taper?: Taper;
/** Diameter in px. Default: 120. */
size?: number;
/** Arc thickness in px. Default: size * 0.09. */
thickness?: number;
/** Start angle (deg clockwise from 12 o'clock) and sweep. Default: 225 / 270. */
angleOffset?: number;
angleRange?: number;
/** Single arc color. Ignored when `zones` is set. Defaults to theme zones. */
color?: string;
/** Color the arc from the value (green/amber/red load). Wins over color/zones. */
valueColor?: (value: number) => string;
/** Normalized color zones (like Meter). Defaults to theme zoneGood/Warn/Hot. */
zones?: readonly LEDFaderZone[];
trackColor?: string;
/** Draw a needle pointing at the value. Default: true. */
needle?: boolean;
/** Center numeric readout. Default: true. */
showValue?: boolean;
digits?: number;
displayDecimals?: number;
/** Unit suffix under the readout, e.g. "%". */
unit?: string;
/** Caption under the gauge. */
label?: string;
labelColor?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* A read-only radial arc meter the round sibling of `Meter`, for CPU / RAM /
* load and any single gauge value. Colors itself by zone as the value climbs.
*/
export const Gauge: React.FC<GaugeProps> = ({
value,
min = 0,
max = 100,
taper = linearTaper,
size = 120,
thickness,
angleOffset = 225,
angleRange = 270,
color,
valueColor,
zones,
trackColor,
needle = true,
showValue = true,
digits = 3,
displayDecimals = 0,
unit,
label,
labelColor,
className,
style,
...aria
}) => {
const theme = useKnobTheme();
const t = thickness ?? Math.max(4, size * 0.09);
const c = size / 2;
const r = c - t / 2 - 1;
const clamped = clamp(value, min, max);
const n = clamp(taper.toNormalized(clamped, min, max), 0, 1);
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
if (color) return [{ upTo: 1, color }];
return [
{ upTo: 0.72, color: theme.zoneGood },
{ upTo: 0.9, color: theme.zoneWarn },
{ upTo: 1, color: theme.zoneHot },
];
}, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]);
const zoneAt = (norm: number) =>
(zoneList.find(z => norm <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
const litColor = valueColor ? valueColor(clamped) : zoneAt(n);
const valueAngle = angleFromNormalized(n, angleOffset, angleRange);
const trackPath = describeArc(c, c, r, angleOffset, angleOffset + angleRange);
const valuePath = n > 0.001 ? describeArc(c, c, r, angleOffset, valueAngle) : '';
const tip = polarToCartesian(c, c, r - t * 0.1, valueAngle);
const tail = polarToCartesian(c, c, t * 0.4, valueAngle);
return (
<div
className={className}
role="meter"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={clamped}
aria-label={aria['aria-label'] ?? label}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
width: size,
...style,
}}
>
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: 'block' }} aria-hidden="true">
<path d={trackPath} fill="none" stroke={trackColor ?? theme.track} strokeWidth={t} strokeLinecap="round" />
{valuePath && (
<path d={valuePath} fill="none" stroke={litColor} strokeWidth={t} strokeLinecap="round" />
)}
{needle && (
<line
x1={tail.x}
y1={tail.y}
x2={tip.x}
y2={tip.y}
stroke={litColor}
strokeWidth={Math.max(2, size * 0.022)}
strokeLinecap="round"
/>
)}
{needle && <circle cx={c} cy={c} r={Math.max(2.5, size * 0.04)} fill={litColor} />}
</svg>
{showValue && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: needle ? '58%' : '50%',
transform: 'translateY(-50%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
pointerEvents: 'none',
}}
>
<SegmentDisplay
value={clamped}
digits={digits}
decimals={displayDecimals}
height={size * 0.16}
color={litColor}
background="none"
ghostOpacity={0.06}
/>
{unit && (
<span style={{ fontFamily: theme.fontMono, fontSize: size * 0.08, color: theme.label }}>{unit}</span>
)}
</div>
)}
</div>
{label && (
<span
style={{
fontFamily: theme.fontUI,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: labelColor ?? theme.label,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
)}
</div>
);
};

View file

@ -92,6 +92,8 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
labelColor,
faceColor = '#0b0d0e',
label,
sublabel,
labelSize,
unit,
focusRing,
className,
@ -150,7 +152,8 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.45}
fontSize={size * 0.095}
fontSize={labelSize ?? size * 0.095}
sublabel={sublabel}
>
{label}
</KnobLabel>

View file

@ -0,0 +1,134 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface LampRowItem {
label: string;
on: boolean;
color?: string;
}
export interface LampRowProps {
lamps: readonly LampRowItem[];
/** Lamp diameter in px. Default: 9. */
size?: number;
/** Caption placement relative to each lamp. Default: 'right'. */
labelPosition?: 'right' | 'below';
/** Make lamps clickable (a compact indicator toggle bank). */
onToggle?: (index: number, on: boolean) => void;
/** Spacing between lamp cells in px. Default: 14. */
gap?: number;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* A row of small indicator lamps with console-print captions the
* `● CUE ● MUTE` bank hardware panels use, standardized. Read-only status by
* default; pass `onToggle` to make the lamps pressable.
*/
export const LampRow: React.FC<LampRowProps> = ({
lamps,
size = 9,
labelPosition = 'right',
onToggle,
gap = 14,
disabled = false,
className,
style,
...aria
}) => {
const theme = useKnobTheme();
const below = labelPosition === 'below';
const interactive = !!onToggle;
return (
<div
className={className}
role="group"
aria-label={aria['aria-label']}
data-disabled={disabled ? '' : undefined}
style={{
display: 'inline-flex',
alignItems: 'center',
gap,
opacity: disabled ? 0.45 : 1,
...style,
}}
>
{lamps.map((lamp, i) => {
const accent = lamp.color ?? theme.accent ?? theme.zoneGood;
const dot = (
<span
aria-hidden="true"
style={{
width: size,
height: size,
flex: 'none',
borderRadius: '50%',
background: lamp.on ? accent : 'rgba(255,255,255,0.1)',
border: '1px solid rgba(0,0,0,0.5)',
boxShadow: lamp.on
? `0 0 ${size * 0.8}px ${accent}, inset 0 0 ${size * 0.25}px rgba(255,255,255,0.5)`
: 'inset 0 1px 2px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms',
}}
/>
);
const caption = (
<span
style={{
fontFamily: theme.fontUI,
fontSize: 9.5,
fontWeight: 600,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: lamp.on ? theme.text : theme.label,
whiteSpace: 'nowrap',
transition: 'color 80ms',
}}
>
{lamp.label}
</span>
);
const inner = (
<span
style={{
display: 'inline-flex',
flexDirection: below ? 'column' : 'row',
alignItems: 'center',
gap: below ? 4 : 6,
}}
>
{dot}
{caption}
</span>
);
return interactive ? (
<button
key={i}
type="button"
role="switch"
aria-checked={lamp.on}
aria-label={lamp.label}
disabled={disabled}
onClick={() => onToggle!(i, !lamp.on)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: disabled ? 'not-allowed' : 'pointer',
font: 'inherit',
}}
>
{inner}
</button>
) : (
<React.Fragment key={i}>{inner}</React.Fragment>
);
})}
</div>
);
};

View file

@ -33,6 +33,8 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
showArc = true,
tickCount = 21,
label,
sublabel,
labelSize,
showValue = false,
unit,
format,
@ -137,7 +139,8 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.44}
fontSize={size * 0.1}
fontSize={labelSize ?? size * 0.1}
sublabel={sublabel}
>
{label}
</KnobLabel>

View file

@ -17,6 +17,14 @@ export interface MeterProps {
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
/**
* Scale to fill the container instead of a fixed px footprint. The SVG keeps
* its `length`×`breadth` aspect ratio; a horizontal meter fills width, a
* vertical one fills height. Great for resizable dock panels.
*/
fill?: boolean;
/** Dim the meter uniformly (matches disabled controls). */
disabled?: boolean;
/** Cross-axis size in px PER CHANNEL. Minimum useful: ~6. Default: 16. */
breadth?: number;
/** Number of LED segments. Default: 28. */
@ -81,6 +89,8 @@ export const Meter: React.FC<MeterProps> = ({
taper = linearTaper,
orientation = 'vertical',
length = 160,
fill = false,
disabled = false,
breadth = 16,
segments = 28,
color,
@ -225,12 +235,15 @@ export const Meter: React.FC<MeterProps> = ({
aria-valuenow={hottestShown}
aria-label={aria['aria-label']}
data-clipped={clipped ? '' : undefined}
data-disabled={disabled ? '' : undefined}
style={{
display: 'inline-flex',
display: fill ? 'flex' : 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 5,
width: w,
width: fill && !vertical ? '100%' : w,
height: fill && vertical ? '100%' : undefined,
opacity: disabled ? 0.45 : undefined,
...style,
}}
>
@ -252,7 +265,14 @@ export const Meter: React.FC<MeterProps> = ({
)}
</div>
)}
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
<svg
width={fill && !vertical ? '100%' : w}
height={fill && vertical ? '100%' : h}
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio={fill ? (vertical ? 'xMidYMax meet' : 'xMinYMid meet') : undefined}
style={{ display: 'block' }}
aria-hidden="true"
>
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
{bars}
{showClip && (

View file

@ -23,6 +23,8 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
labelColor,
arcFrom = 'min',
label,
sublabel,
labelSize,
showValue = true,
unit,
format,
@ -85,7 +87,8 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.45}
fontSize={size * 0.1}
fontSize={labelSize ?? size * 0.1}
sublabel={sublabel}
>
{label}
</KnobLabel>

View file

@ -23,6 +23,8 @@ export interface PushButtonProps {
disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */
name?: string;
/** Icon rendered before the caption (e.g. a lucide glyph), tinted to the LED. */
leadingIcon?: React.ReactNode;
/** Caption, e.g. "MUTE". */
children?: React.ReactNode;
className?: string;
@ -44,6 +46,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width,
disabled = false,
name,
leadingIcon,
children,
className,
style,
@ -167,7 +170,23 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
}}
/>
)}
{children}
{(leadingIcon || children) && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, lineHeight: 1 }}>
{leadingIcon && (
<span
aria-hidden="true"
style={{
display: 'inline-flex',
color: on ? accent : theme.label,
transition: 'color 80ms',
}}
>
{leadingIcon}
</span>
)}
{children}
</span>
)}
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
</button>
);

View file

@ -25,6 +25,8 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
labelColor,
arcFrom = 'min',
label,
sublabel,
labelSize,
showValue = false,
unit,
format,
@ -103,7 +105,8 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.44}
fontSize={size * 0.1}
fontSize={labelSize ?? size * 0.1}
sublabel={sublabel}
>
{label}
</KnobLabel>

View file

@ -29,6 +29,8 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
textColor,
labelColor,
label,
sublabel,
labelSize,
showValue = true,
format,
focusRing,
@ -106,7 +108,8 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.45}
fontSize={size * 0.095}
fontSize={labelSize ?? size * 0.095}
sublabel={sublabel}
>
{label}
</KnobLabel>

View file

@ -0,0 +1,195 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface ToggleSwitchProps {
/** Controlled on-state. Pair with `onChange`. */
on?: boolean;
defaultOn?: boolean;
onChange?: (on: boolean) => void;
/** Accent color when on. Defaults to the theme accent. */
color?: string;
/** Track height in px (the knob is sized from it). Default: 22. */
size?: number;
/** Caption beside the switch. */
label?: string;
labelPosition?: 'right' | 'left';
/** Tiny OFF/ON legend printed on the track. Default: false. */
showStateLabel?: boolean;
/** Text for the on/off legend when `showStateLabel`. Default: 'ON'/'OFF'. */
onLabel?: string;
offLabel?: string;
disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */
name?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* A rocker/slider switch the most literal boolean idiom. Renders
* `role="switch"`; the knob slides between OFF and ON. Space/Enter toggles,
* / set off/on.
*/
export const ToggleSwitch = React.forwardRef<HTMLButtonElement, ToggleSwitchProps>(
function ToggleSwitch(
{
on,
defaultOn = false,
onChange,
color,
size = 22,
label,
labelPosition = 'right',
showStateLabel = false,
onLabel = 'ON',
offLabel = 'OFF',
disabled = false,
name,
className,
style,
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
const isControlled = on !== undefined;
const [internal, setInternal] = React.useState(defaultOn);
const [focusVisible, setFocusVisible] = React.useState(false);
const lit = isControlled ? (on as boolean) : internal;
const set = (next: boolean) => {
if (next === lit) return;
if (!isControlled) setInternal(next);
onChange?.(next);
};
const trackW = size * 1.85;
const trackH = size;
const knobD = size - 6;
const pad = 3;
const knobX = lit ? trackW - knobD - pad : pad;
const legend = (side: 'on' | 'off') => (
<span
aria-hidden="true"
style={{
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
[side === 'on' ? 'left' : 'right']: knobD + pad + 2,
fontFamily: theme.fontUI,
fontSize: Math.max(7, size * 0.34),
fontWeight: 700,
letterSpacing: '0.06em',
color: side === 'on' ? 'rgba(0,0,0,0.72)' : 'rgba(255,255,255,0.4)',
opacity: (side === 'on') === lit ? 1 : 0,
transition: 'opacity 120ms',
pointerEvents: 'none',
}}
>
{side === 'on' ? onLabel : offLabel}
</span>
);
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={lit}
aria-label={aria['aria-label'] ?? label}
disabled={disabled}
data-on={lit ? '' : undefined}
data-focus-visible={focusVisible ? '' : undefined}
className={className}
onClick={() => set(!lit)}
onKeyDown={e => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
set(false);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
set(true);
}
}}
onFocus={e => {
try {
setFocusVisible(e.currentTarget.matches(':focus-visible'));
} catch {
setFocusVisible(true);
}
}}
onBlur={() => setFocusVisible(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
flexDirection: labelPosition === 'left' ? 'row-reverse' : 'row',
gap: Math.max(6, size * 0.4),
padding: 3,
background: 'none',
border: 'none',
borderRadius: 8,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.45 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
touchAction: 'manipulation',
outline: 'none',
boxShadow: focusVisible ? `0 0 0 2px ${theme.focusRing}` : undefined,
...style,
}}
>
<span
aria-hidden="true"
style={{
position: 'relative',
display: 'inline-block',
flex: 'none',
width: trackW,
height: trackH,
borderRadius: trackH / 2,
background: lit ? accent : 'rgba(255,255,255,0.1)',
border: '1px solid rgba(0,0,0,0.55)',
boxShadow: lit
? `inset 0 0 ${size * 0.3}px rgba(0,0,0,0.25)`
: 'inset 0 1px 3px rgba(0,0,0,0.6)',
transition: 'background 120ms',
}}
>
{showStateLabel && legend('on')}
{showStateLabel && legend('off')}
<span
style={{
position: 'absolute',
top: pad,
left: knobX,
width: knobD,
height: knobD,
borderRadius: '50%',
background: 'linear-gradient(180deg, #f4f5f7, #cfd1d6)',
boxShadow: '0 1px 2px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.8)',
transition: 'left 130ms cubic-bezier(0.3, 0.7, 0.4, 1)',
}}
/>
</span>
{label && (
<span
style={{
fontFamily: theme.fontUI,
fontSize: Math.max(11, size * 0.55),
letterSpacing: '0.04em',
color: lit ? theme.text : theme.label,
whiteSpace: 'nowrap',
transition: 'color 120ms',
}}
>
{label}
</span>
)}
{name && <input type="hidden" name={name} value={lit ? 'on' : 'off'} readOnly />}
</button>
);
},
);

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

View file

@ -103,6 +103,8 @@ export const VintageKnob = React.forwardRef<HTMLDivElement, VintageKnobProps>(fu
scaleLabels,
indicatorColor,
label,
sublabel,
labelSize,
focusRing,
className,
style,
@ -132,7 +134,8 @@ export const VintageKnob = React.forwardRef<HTMLDivElement, VintageKnobProps>(fu
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.46}
fontSize={size * 0.095}
fontSize={labelSize ?? size * 0.095}
sublabel={sublabel}
>
{label}
</KnobLabel>

View file

@ -9,6 +9,13 @@ export interface SkinProps extends KnobCoreProps {
size?: number;
/** Caption drawn in the travel gap at the bottom of the knob. */
label?: string;
/**
* Secondary caption line under the label a unit or live descriptor
* (`SEED` over `world #7`). Rendered smaller and un-uppercased.
*/
sublabel?: React.ReactNode;
/** Label font size in px, independent of `size` (small knobs, readable labels). */
labelSize?: number;
/** Show the numeric readout. Default varies per skin. */
showValue?: boolean;
/** Unit suffix for the readout, e.g. "dB", "Hz", "%". */