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

@ -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}
>