DreamKnob/packages/dreamknob/src/skins/SteppedKnob.tsx
Dreamodus 5b97091902 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.
2026-07-13 19:20:23 -07:00

122 lines
3.6 KiB
TypeScript

import * as React from 'react';
import { Knob } from '../components/Knob';
import { Pointer } from '../primitives/Pointer';
import { Ticks } from '../primitives/Ticks';
import { KnobLabel } from '../primitives/Text';
import { useKnobTheme } from '../core/theme';
import type { SkinProps } from './shared';
export interface SteppedKnobProps extends SkinProps {
/** Position names, e.g. ['LP','BP','HP']. Sets the number of detents. */
positions?: readonly string[];
/** Number of detents when `positions` is not given. */
steps?: number;
color?: string;
tickColor?: string;
faceColor?: string;
textColor?: string;
labelColor?: string;
}
/** Detented selector knob — mode/range switches with hard stops. */
export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(function SteppedKnob({
size = 90,
positions,
steps,
color: colorProp,
tickColor,
faceColor = '#1c1d22',
textColor,
labelColor,
label,
sublabel,
labelSize,
showValue = true,
format,
focusRing,
className,
style,
...core
}, ref) {
const theme = useKnobTheme();
const color = colorProp ?? theme.accent ?? '#ffd23e';
const detents = positions ? positions.length : steps ?? 5;
const min = core.min ?? 0;
const max = core.max ?? (positions ? positions.length - 1 : 100);
return (
<Knob
ref={ref}
size={size}
className={className}
style={style}
focusRing={focusRing}
{...core}
min={min}
max={max}
steps={detents}
getAriaValueText={core.getAriaValueText ?? (positions ? (v: number) => positions[Math.round(((v - min) / (max - min || 1)) * (detents - 1))] ?? String(v) : format ? (v: number) => format(v) : undefined)}
>
{ctx => {
const index = Math.round(ctx.normalized * (detents - 1));
return (
<>
<Ticks
count={detents}
radius={size / 2 - 1}
length={size * 0.07}
width={Math.max(1.5, size * 0.022)}
color={tickColor ?? theme.ticks}
getTickProps={i => (i === index ? { stroke: color } : undefined)}
/>
<circle
cx={ctx.center}
cy={ctx.center}
r={size / 2 - size * 0.14}
fill={faceColor}
stroke="rgba(255,255,255,0.1)"
strokeWidth="1"
/>
<Pointer
type="triangle"
radius={size / 2 - size * 0.15}
length={size * 0.14}
width={size * 0.09}
color={color}
/>
{showValue && (
<text
x={ctx.center}
y={ctx.center}
textAnchor="middle"
dominantBaseline="central"
fontSize={size * (positions ? 0.15 : 0.17)}
fill={textColor ?? theme.text}
fontFamily={theme.fontMono}
fontWeight={600}
style={{ pointerEvents: 'none' }}
>
{positions
? positions[index]
: format
? format(ctx.value)
: ctx.value.toFixed(ctx.decimals)}
</text>
)}
{label && (
<KnobLabel
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.45}
fontSize={labelSize ?? size * 0.095}
sublabel={sublabel}
>
{label}
</KnobLabel>
)}
</>
);
}}
</Knob>
);
});