v1.1.0: integration-feedback release

Bugs from real-product integration:
- Meter/LEDFader clamp geometry below breadth 14 (no negative SVG rects)
- mixColors falls back to CSS color-mix() so theme tokens can be var()/oklch()
- type-in editing fires a real edit gesture (Start/onChange/End, source 'edit')
- ScrubField Enter/Escape no longer double-commit or commit-on-revert via blur
- disabled dims the whole control uniformly, data-disabled on the wrapper

New capabilities:
- commitMode 'release': drags preview, emit once on pointer-up, Esc discards
- Meter accepts value=[l, r, ...]; theme zoneGood/zoneWarn/zoneHot defaults
- XYPad per-axis invert + detents; Fader valuePosition top|end|none
- LED skins: unit labels, uncapped displayDecimals, aria-valuetext defaults

New components: IndicatorLamp, SegmentSwitch, ScrubField, MeterBridge,
PushButton led='dot'. Docs: change-contract table, recipes, gallery cards.
This commit is contained in:
Dreamodus 2026-07-13 12:07:52 -07:00
parent b3eaa604c2
commit d8b7b55505
31 changed files with 1471 additions and 223 deletions

View file

@ -35,8 +35,9 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log / rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
power / custom tapers. power / custom tapers.
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`, - **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
`SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `XYPad`, `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `MeterBridge`,
`PushButton`, `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance. `XYPad`, `PushButton`, `IndicatorLamp`, `SegmentSwitch`, `ScrubField`,
`SegmentDisplay`, `AlphaDisplay` — every color themeable per instance.
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`, - **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with `KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
`useKnob`. `useKnob`.
@ -48,7 +49,7 @@ See `packages/dreamknob/README.md` and the docs app for the full API.
The easiest way is the published release on our Forgejo: The easiest way is the published release on our Forgejo:
```bash ```bash
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz
``` ```
Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types): Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types):

View file

@ -67,8 +67,9 @@ export const ApiDocs: React.FC = () => (
<Row name="wheelStep" type="number" def="step (else 1% of range)" desc="Value change per wheel notch, independent of the snapping step." /> <Row name="wheelStep" type="number" def="step (else 1% of range)" desc="Value change per wheel notch, independent of the snapping step." />
<Row name="wheelRequiresFocus" type="boolean" def="false" desc="Only respond to the wheel while focused — hovering no longer captures page scroll." /> <Row name="wheelRequiresFocus" type="boolean" def="false" desc="Only respond to the wheel while focused — hovering no longer captures page scroll." />
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." /> <Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
<Row name="onChange" type="(v, meta) => void" desc="Fires for every distinct snapped value. meta.source tells you what caused it: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'." /> <Row name="onChange" type="(v, meta) => void" desc="Fires for every distinct snapped value. meta.source tells you what caused it: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'edit' | 'api'." />
<Row name="onChangeStart / onChangeEnd" type="(v, meta) => void" desc="Gesture boundaries (drag, wheel burst, key press, reset) — ideal for undo history or parameter automation." /> <Row name="onChangeStart / onChangeEnd" type="(v, meta) => void" desc="Gesture boundaries (drag, wheel burst, key press, reset, type-in edit) — ideal for undo history or parameter automation." />
<Row name="commitMode" type="'live' | 'release'" def="'live'" desc="'release' previews drags visually but emits onChange only once, on pointer-up — for expensive parameters (reverb sizes, engine restarts). Wheel/keyboard still emit live; Esc discards the preview." />
<Row name="◇ name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work (XYPad uses nameX/nameY)." /> <Row name="◇ name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work (XYPad uses nameX/nameY)." />
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." /> <Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
<Row name="◇ editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." /> <Row name="◇ editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." />
@ -87,21 +88,56 @@ export const ApiDocs: React.FC = () => (
via pointer capture · every control shows a keyboard-only focus ring. via pointer capture · every control shows a keyboard-only focus ring.
</p> </p>
<h3 className="api-h">The change contract</h3>
<p className="api-note">
Every way a value can move maps to a <code>ChangeMeta.source</code>, and every
user gesture is bracketed by exactly one <code>onChangeStart</code> /{' '}
<code>onChangeEnd</code> pair wire undo snapshots to Start and history commits
to End and the ledger always balances.
</p>
<table className="api-table">
<thead>
<tr>
<th>source</th>
<th>Emitted by</th>
<th>Start/End?</th>
<th>onChange cadence</th>
</tr>
</thead>
<tbody>
<tr><td>'drag'</td><td>pointer drags (rotary, linear, track, XY, scrub)</td><td>yes one pair per drag</td><td>each snapped value ('live') or once on release ('release')</td></tr>
<tr><td>'wheel'</td><td>scroll wheel / trackpad</td><td>yes one pair per burst (150250 ms settle)</td><td>each notch</td></tr>
<tr><td>'keyboard'</td><td>arrows, PageUp/Down, Home/End</td><td>yes one pair per press</td><td>each step</td></tr>
<tr><td>'reset'</td><td>double-click reset</td><td>yes</td><td>once</td></tr>
<tr><td>'edit'</td><td>type-in editors (editable, ScrubField)</td><td>yes</td><td>once, on commit</td></tr>
<tr><td>'api'</td><td>setValue() from your code</td><td><strong>no</strong> programmatic moves are not gestures</td><td>each call that lands on a new value</td></tr>
</tbody>
</table>
<p className="api-note">
A cancelled gesture (Esc, or <code>disabled</code> flipping mid-drag) restores the
start value and still fires <code>onChangeEnd</code>, so Start/End counts always
match. <code>onChange</code> never repeats a value within a gesture.
</p>
<h3 className="api-h">Prebuilt skins</h3> <h3 className="api-h">Prebuilt skins</h3>
<Table> <Table>
<Row name="<FlatKnob />" type="2D" desc="Modern arc knob. color, trackColor, faceColor, pointerColor, textColor, arcFrom ('min' | 'center'), arcThickness." /> <Row name="<FlatKnob />" type="2D" desc="Modern arc knob. color, trackColor, faceColor, pointerColor, textColor, arcFrom ('min' | 'center'), arcThickness." />
<Row name="<MetalKnob />" type="3D" desc="Brushed aluminum. tone ('silver' | 'dark'), color, tickCount, showArc, indicatorColor." /> <Row name="<MetalKnob />" type="3D" desc="Brushed aluminum. tone ('silver' | 'dark'), color, tickCount, showArc, indicatorColor." />
<Row name="<RubberKnob />" type="3D" desc="Soft-touch synth knob with glow halo. color, trackColor, arcFrom." /> <Row name="<RubberKnob />" type="3D" desc="Soft-touch synth knob with glow halo. color, trackColor, arcFrom." />
<Row name="<VintageKnob />" type="analog" desc="Chicken-head pointer. bodyColor (cream/bakelite/any), scaleColor, scaleLabels." /> <Row name="<VintageKnob />" type="analog" desc="Chicken-head pointer. bodyColor (cream/bakelite/any), scaleColor, scaleLabels." />
<Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals." /> <Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals (any precision), unit label under the digits." />
<Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." /> <Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." />
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." /> <Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." /> <Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format, valuePosition ('top' | 'end' | 'none' — 'end' gives one-line inspector rows)." />
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." /> <Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}] — defaults to the theme's green/amber/red), glow, digits, unit." />
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." /> <Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
<Row name="<Meter />" type="display" desc="Read-only LED level meter: value, zones, peakHold (ms), peakColor, orientation, seven-segment readout. Clip LED: showClip (default on), clipThreshold (default max), clipHold (ms or 'latch', click to clear), clipColor, onClip." /> <Row name="<Meter />" type="display" desc="Read-only LED level meter: value (number or [l, r, ...] for multi-channel), zones (defaults to theme green/amber/red), peakHold (ms), peakColor, orientation, unit, seven-segment readout. Clip LED: showClip (default on), clipThreshold (default max), clipHold (ms or 'latch', click to clear), clipColor, onClip." />
<Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs, drag + arrow keys, grid, crosshair, Escape cancel, double-click reset." /> <Row name="<MeterBridge />" type="display" desc="Labeled multi-strip meter block: channels ([{label, value, clipValue?}]), per-channel peak-hold text, shared clip LED — the mixer meter row, prebuilt." />
<Row name="<PushButton />" type="switch" desc="Panel button with LED strip: toggle or momentary, controlled/uncontrolled, aria-pressed." /> <Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs (each with min/max/step/invert/detents), drag + arrow keys (arrows follow the visual direction), grid, crosshair, Escape cancel, double-click reset." />
<Row name="<PushButton />" type="switch" desc="Panel button: toggle or momentary, controlled/uncontrolled, aria-pressed. led: 'strip' (default) or 'dot' — the corner dot scales down for small icon-only toggles." />
<Row name="<IndicatorLamp />" type="switch · display" desc="Panel LED lamp: pressable toggle (role='switch') or read-only status light, blink, momentary, label either side — the checkbox, in hardware form." />
<Row name="<SegmentSwitch />" type="selector" desc="Segmented option switch (radiogroup): options list, LED tick on the active cell, arrow/Home/End keys, controlled/uncontrolled index." />
<Row name="<ScrubField />" type="numeric field" desc="After-Effects-style number field: drag the label to scrub (Shift = fine), click to type (Enter commits, Esc reverts), arrows step, wheel nudges. role='spinbutton'." />
<Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: AZ 09 and symbols, chars padding, align, glow, skew." /> <Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: AZ 09 and symbols, chars padding, align, glow, skew." />
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." /> <Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
</Table> </Table>
@ -118,13 +154,19 @@ export const ApiDocs: React.FC = () => (
<code>label</code>) and every interactive root exposes{' '} <code>label</code>) and every interactive root exposes{' '}
<code>data-dragging</code>/<code>data-disabled</code>/<code>data-focus-visible</code>{' '} <code>data-dragging</code>/<code>data-disabled</code>/<code>data-focus-visible</code>{' '}
style any state or part with plain CSS. <code>&lt;Arc&gt;</code> additionally style any state or part with plain CSS. <code>&lt;Arc&gt;</code> additionally
accepts <code>gradient</code> (position-anchored color stops). accepts <code>gradient</code> (position-anchored color stops). Geometry is
self-defending: <code>Meter</code>/<code>LEDFader</code> clamp their internal
padding below <code>breadth</code> 14 and never emit negative dimensions, so
4-px status slivers render fine.
</p> </p>
<h3 className="api-h">Theming</h3> <h3 className="api-h">Theming</h3>
<p className="api-note"> <p className="api-note">
Every skin resolves its color and font defaults from the active theme; per-instance Every skin resolves its color and font defaults from the active theme; per-instance
props always win. Use <code>base="light"</code> for light backgrounds. props always win. Use <code>base="light"</code> for light backgrounds. Any CSS
color works hex, <code>rgb()</code>, <code>oklch()</code>, even{' '}
<code>var(--accent)</code>: gradient and glow math uses hex fast-paths where it
can and falls back to CSS <code>color-mix()</code> everywhere else.
</p> </p>
<CodeBlock <CodeBlock
code={`import { DreamknobProvider } from 'dreamknob' code={`import { DreamknobProvider } from 'dreamknob'
@ -137,6 +179,9 @@ export const ApiDocs: React.FC = () => (
text: '#fff', text: '#fff',
label: 'rgba(255,255,255,0.5)', label: 'rgba(255,255,255,0.5)',
fontMono: '"IBM Plex Mono", monospace', fontMono: '"IBM Plex Mono", monospace',
zoneGood: '#3df2ad', // meter zones: green / amber / red
zoneWarn: '#ffd23e', // used by Meter, MeterBridge and
zoneHot: '#ff4d6b', // LEDFader when no zones are given
}} }}
> >
<FlatKnob ... /> <Fader ... /> <LEDKnob ... /> <FlatKnob ... /> <Fader ... /> <LEDKnob ... />
@ -189,6 +234,40 @@ function MyKnob() {
}`} }`}
/> />
<h3 className="api-h">Recipes</h3>
<p className="api-note">
The prop combinations we reach for in real consoles and inspectors.
</p>
<CodeBlock
code={`// Bipolar gain — arc grows from center, double-click back to 0
<FlatKnob min={-12} max={12} step={0.1} defaultValue={0}
origin={0} detents={[0]} unit=" dB" aria-label="Gain" />
// Angle dial — endless, wraps 0↔360, full-circle travel
<FlatKnob min={0} max={360} step={1} wrap
angleOffset={0} angleRange={360} unit="°" aria-label="Angle" />
// Expensive parameter — drag previews, engine hears one change on release
<MetalKnob min={0.1} max={10} step={0.1} commitMode="release"
onChange={v => rebuildReverbTail(v)} aria-label="Room size" />
// Wide-range filter cutoff — log taper puts the musical range under your thumb
<RubberKnob min={20} max={20000} taper={logTaper} step={1}
format={v => v >= 1000 ? (v / 1000).toFixed(1) + ' kHz' : v + ' Hz'}
aria-label="Cutoff" />
// Inspector knob — relative drag (no grab-jump), flick fast / drag slow
<FlatKnob size={40} rotaryMode="relative" dragAcceleration={0.5}
wheelRequiresFocus editable aria-label="Opacity" />
// One-line inspector row — fader with the readout beside it
<Fader orientation="horizontal" length={140} valuePosition="end"
editable unit="px" aria-label="Radius" />
// Stereo meter — one component, two channels, shared clip LED
<Meter value={[left, right]} min={-60} max={6} clipThreshold={0} />`}
/>
<h3 className="api-h">Precision &amp; number handling</h3> <h3 className="api-h">Precision &amp; number handling</h3>
<p className="api-note"> <p className="api-note">
Values are snapped to <code>step</code> (anchored at <code>min</code>) and rounded Values are snapped to <code>step</code> (anchored at <code>min</code>) and rounded

View file

@ -8,14 +8,18 @@ import {
ImageKnob, ImageKnob,
Knob, Knob,
KnobValue, KnobValue,
IndicatorLamp,
LEDFader, LEDFader,
LEDKnob, LEDKnob,
MetalKnob, MetalKnob,
Meter, Meter,
MeterBridge,
NeonKnob, NeonKnob,
Pointer, Pointer,
PushButton, PushButton,
RubberKnob, RubberKnob,
ScrubField,
SegmentSwitch,
SegmentDisplay, SegmentDisplay,
SteppedKnob, SteppedKnob,
Ticks, Ticks,
@ -117,15 +121,78 @@ const MeterDemo: React.FC = () => {
}, 90); }, 90);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
const zones = [
{ upTo: 0.72, color: '#3df2ad' },
{ upTo: 0.9, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
];
return ( return (
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 22, alignItems: 'flex-end' }}>
<Meter value={levels[0]} min={-60} max={6} length={132} zones={zones} peakColor="#fff" label="L" aria-label="Left level" /> <Meter
<Meter value={levels[1]} min={-60} max={6} length={132} zones={zones} peakColor="#fff" label="R" aria-label="Right level" /> value={levels}
min={-60}
max={6}
length={132}
peakColor="#fff"
label="L / R"
aria-label="Stereo level"
/>
<MeterBridge
channels={[
{ label: 'L', value: levels[0] },
{ label: 'R', value: levels[1] },
{ label: 'M', value: (levels[0] + levels[1]) / 2 },
{ label: 'S', value: (levels[0] - levels[1]) / 2 - 20 },
]}
min={-60}
max={6}
length={132}
breadth={20}
peakTextDecimals={0}
label="Bridge"
aria-label="Meter bridge"
/>
</div>
);
};
const LampSwitchDemo: React.FC = () => {
const [mode, setMode] = React.useState(1);
const [armed, setArmed] = React.useState(true);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<SegmentSwitch
options={['LP', 'BP', 'HP', 'NOTCH']}
value={mode}
onChange={setMode}
color="#4cc2ff"
aria-label="Filter type"
/>
<div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
<IndicatorLamp on={armed} onChange={setArmed} color="#ff4d6b" label="REC" aria-label="Record arm" />
<IndicatorLamp on={armed} readOnly blink color="#ff4d6b" label="SIG" aria-label="Signal lamp" />
<PushButton led="dot" size={30} color="#3df2ad" defaultPressed aria-label="Snap toggle">
</PushButton>
</div>
</div>
);
};
const CommitModeDemo: React.FC = () => {
const [commits, setCommits] = React.useState(0);
return (
<div style={{ display: 'flex', gap: 22, alignItems: 'center' }}>
<FlatKnob
size={84}
defaultValue={35}
color="#ffd23e"
label="Release"
commitMode="release"
onChange={() => setCommits(n => n + 1)}
aria-label="Release commit demo"
/>
<div data-commit-count={commits} style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
<SegmentDisplay value={commits} digits={3} decimals={0} height={20} color="#ffd23e" />
<span style={{ fontSize: 10, letterSpacing: '0.08em', color: 'rgba(232,234,240,0.45)', textTransform: 'uppercase' }}>
onChange calls
</span>
</div>
</div> </div>
); );
}; };
@ -343,10 +410,25 @@ export const Gallery: React.FC = () => (
</div> </div>
</Card> </Card>
<Card title="Meter" desc="Read-only LED metering with peak hold and a latching clip LED — click it to clear." tag="<Meter clipHold={1500} />"> <Card title="Meters" desc="Stereo metering from one component (value={[l, r]}), or a full labeled bridge with per-channel peak text. Click the clip LED to clear it." tag="<Meter /> · <MeterBridge />">
<MeterDemo /> <MeterDemo />
</Card> </Card>
<Card title="Lamps & switches" desc="A segmented option switch, pressable indicator lamps (blink included) and a corner-dot button — the whole panel family." tag="<SegmentSwitch /> · <IndicatorLamp />">
<LampSwitchDemo />
</Card>
<Card title="Scrub field" desc="After-Effects-style numbers: drag the label to scrub, click to type, Shift for fine. '1.2k' parses too." tag="<ScrubField />">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<ScrubField label="Width" defaultValue={1280} min={0} max={4096} unit="px" aria-label="Width" />
<ScrubField label="Scale" defaultValue={1} min={0} max={10} step={0.01} widthCh={5} unit="×" aria-label="Scale" />
</div>
</Card>
<Card title="Release commit" desc="commitMode='release' previews the drag but your app hears one onChange, on pointer-up — for parameters that are expensive to apply." tag="commitMode='release'">
<CommitModeDemo />
</Card>
<Card title="Gradient arc" desc="Position-anchored gradient — the red zone stays at the top of travel." tag="<Arc gradient={...} />"> <Card title="Gradient arc" desc="Position-anchored gradient — the red zone stays at the top of travel." tag="<Arc gradient={...} />">
<Knob size={96} min={-60} max={6} step={0.5} defaultValue={-8} aria-label="Gradient arc demo"> <Knob size={96} min={-60} max={6} step={0.5} defaultValue={-8} aria-label="Gradient arc demo">
<Arc <Arc

View file

@ -30,12 +30,12 @@ export const GettingStarted: React.FC = () => (
<Step n={1} title="Install"> <Step n={1} title="Install">
<CodeBlock <CodeBlock
code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz\n# (or npm install <same url>) · peer deps: react >= 18, react-dom >= 18`} code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz\n# (or npm install <same url>) · peer deps: react >= 18, react-dom >= 18`}
/> />
<p className="api-note" style={{ marginTop: 10 }}> <p className="api-note" style={{ marginTop: 10 }}>
Installs straight from the{' '} Installs straight from the{' '}
<a href="https://git.dreamodus.software/Dreamodus/DreamKnob/releases"> <a href="https://git.dreamodus.software/Dreamodus/DreamKnob/releases">
v1.0.0 release v1.1.0 release
</a>{' '} </a>{' '}
on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No
CSS file to import everything is SVG and inline styles. CSS file to import everything is SVG and inline styles.

View file

@ -25,10 +25,10 @@ export const Hero: React.FC = () => {
range, any decimal precision, and colors you fully control. range, any decimal precision, and colors you fully control.
</p> </p>
<div className="install"> <div className="install">
<span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz</span> <span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz</span>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz'); navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz');
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1400); setTimeout(() => setCopied(false), 1400);
}} }}

View file

@ -1,5 +1,47 @@
# Changelog # Changelog
## 1.1.0 — 2026-07-13
Integration-feedback release — everything in here came from wiring dreamknob into a
real product.
### New components
- `IndicatorLamp` — pressable LED lamp (`role="switch"`), toggle/momentary, `blink`
(reduced-motion aware), `readOnly` status-light mode
- `SegmentSwitch` — hardware-styled segmented selector (radiogroup, roving focus,
arrow/Home/End keys, LED tick)
- `ScrubField` — After-Effects-style numeric field: label scrubbing (Shift = fine),
draft typing (Enter commits, Esc reverts), arrow steps, wheel; `role="spinbutton"`
- `MeterBridge` — labeled multi-strip meter block with per-channel peak-hold text
- `PushButton` gains `led="dot"` — corner-dot LED that scales down for small
icon-only toggles (`led` now `boolean | 'strip' | 'dot'`)
### New capabilities
- `commitMode="release"` — drags preview visually but emit a single `onChange` on
pointer-up; Esc discards the preview (expensive parameters)
- `Meter` accepts `value={[l, r, ...]}` for multi-channel bars with a shared clip LED
- Theme gains `zoneGood` / `zoneWarn` / `zoneHot`; `Meter`, `MeterBridge` and
`LEDFader` default their zones from the theme
- `XYPad` axes accept `invert` and per-axis `detents`; arrow keys follow the visual
direction on inverted axes
- `Fader` gains `valuePosition: 'top' | 'end' | 'none'``'end'` renders one-line
inspector rows with a stable readout width
- `LEDKnob` / `LEDFader` / `Meter` gain `unit`; `displayDecimals` is no longer capped
- Skins without visible readouts now default `aria-valuetext` from `format`/`unit`
(`SteppedKnob` announces position names)
### Fixes
- `Meter` clamps geometry below `breadth` 14 — no negative-dimension SVG rects on
thin strips
- `mixColors` falls back to CSS `color-mix()` for non-hex inputs, so theme tokens can
be `var(--x)` / `oklch()` without breaking gradients and glows
- Type-in editing now fires a proper edit gesture: `onChangeStart``onChange`
`onChangeEnd` with `source: 'edit'` (was a bare `'api'` set with no gesture events)
- The `ChangeMeta.source` contract is documented (docs + `types.ts`): every user
gesture brackets exactly one Start/End pair; `'api'` fires neither
- Disabled controls set `data-disabled` on the wrapper (including labels/readouts)
and dim uniformly
## 1.0.0 — 2026-07-12 ## 1.0.0 — 2026-07-12
Initial release. Initial release.

View file

@ -5,7 +5,7 @@ headless core, any number range, any decimal precision, and colors you fully con
Zero runtime dependencies. Zero runtime dependencies.
``` ```
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz
``` ```
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases) (Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
@ -98,9 +98,13 @@ import {
Fader, // linear channel fader, vertical or horizontal Fader, // linear channel fader, vertical or horizontal
LEDFader, // segmented LED meter-fader with color zones LEDFader, // segmented LED meter-fader with color zones
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips) ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
Meter, // read-only LED level meter with peak hold + latching clip LED Meter, // read-only LED level meter — mono or multi-channel (value={[l, r]})
MeterBridge, // labeled multi-strip meter block with per-channel peak text
XYPad, // two-parameter pad (cutoff/resonance, vector mixing) XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
PushButton, // panel button with LED strip (toggle or momentary) PushButton, // panel button with LED strip or corner dot (toggle or momentary)
IndicatorLamp, // pressable LED lamp — the hardware checkbox (blink, momentary)
SegmentSwitch, // segmented option selector (radiogroup, LED tick)
ScrubField, // AE-style numeric field: drag the label to scrub, click to type
SegmentDisplay, // standalone seven-segment numeric display SegmentDisplay, // standalone seven-segment numeric display
AlphaDisplay, // fourteen-segment alphanumeric display AlphaDisplay, // fourteen-segment alphanumeric display
} from 'dreamknob' } from 'dreamknob'

View file

@ -1,6 +1,6 @@
{ {
"name": "dreamknob", "name": "dreamknob",
"version": "1.0.0", "version": "1.1.0",
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.", "description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
"license": "MIT", "license": "MIT",
"author": "Dreamodus Software Inc.", "author": "Dreamodus Software Inc.",

View file

@ -127,7 +127,7 @@ export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(function Knob(
initial={String(knob.value)} initial={String(knob.value)}
onCommit={raw => { onCommit={raw => {
const parsed = (parseValue ?? defaultParseValue)(raw); const parsed = (parseValue ?? defaultParseValue)(raw);
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed); if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
closeEditor(); closeEditor();
}} }}
onCancel={closeEditor} onCancel={closeEditor}

View file

@ -10,9 +10,13 @@ describe('mixColors', () => {
it('expands shorthand hex', () => { it('expands shorthand hex', () => {
expect(mixColors('#f00', '#f00', 0.5)).toBe('#ff0000'); expect(mixColors('#f00', '#f00', 0.5)).toBe('#ff0000');
}); });
it('falls back to the nearer endpoint for non-hex input', () => { it('produces color-mix() for non-hex input (CSS vars, named colors)', () => {
expect(mixColors('red', '#00ff00', 0.2)).toBe('red'); expect(mixColors('var(--accent)', '#00ff00', 0.25)).toBe(
expect(mixColors('red', '#00ff00', 0.8)).toBe('#00ff00'); 'color-mix(in srgb, #00ff00 25%, var(--accent))',
);
expect(mixColors('red', 'rgb(0, 255, 0)', 0.5)).toBe(
'color-mix(in srgb, rgb(0, 255, 0) 50%, red)',
);
}); });
}); });

View file

@ -13,11 +13,19 @@ const parseHex = (color: string): [number, number, number, number] | null => {
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]; return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
}; };
/** Mix two hex colors. Non-hex inputs fall back to the nearer endpoint. */ /**
* Mix two colors. Hex inputs are interpolated numerically; anything else
* (var(--x), rgb(), named colors, color-mix() ) falls back to a CSS
* `color-mix()` expression, which SVG fills/strokes accept in modern
* browsers so theme tokens may be any resolvable CSS color.
*/
export const mixColors = (a: string, b: string, t: number): string => { export const mixColors = (a: string, b: string, t: number): string => {
const ca = parseHex(a); const ca = parseHex(a);
const cb = parseHex(b); const cb = parseHex(b);
if (!ca || !cb) return t < 0.5 ? a : b; if (!ca || !cb) {
const pct = Math.round(clamp01(t) * 1000) / 10;
return `color-mix(in srgb, ${b} ${pct}%, ${a})`;
}
const lerp = (x: number, y: number) => Math.round(x + (y - x) * t); const lerp = (x: number, y: number) => Math.round(x + (y - x) * t);
const [r, g, bl, al] = [0, 1, 2, 3].map(i => lerp(ca[i], cb[i])); const [r, g, bl, al] = [0, 1, 2, 3].map(i => lerp(ca[i], cb[i]));
const hex = (v: number) => v.toString(16).padStart(2, '0'); const hex = (v: number) => v.toString(16).padStart(2, '0');
@ -26,6 +34,8 @@ export const mixColors = (a: string, b: string, t: number): string => {
: `#${hex(r)}${hex(g)}${hex(bl)}${hex(al)}`; : `#${hex(r)}${hex(g)}${hex(bl)}${hex(al)}`;
}; };
const clamp01 = (t: number) => Math.max(0, Math.min(1, t));
/** /**
* Sample a gradient at normalized position `t`. Stops are sorted by `at`; * Sample a gradient at normalized position `t`. Stops are sorted by `at`;
* positions outside the stops clamp to the nearest end. * positions outside the stops clamp to the nearest end.

View file

@ -28,6 +28,12 @@ export interface KnobTheme {
ticks: string; ticks: string;
/** Keyboard focus ring. */ /** Keyboard focus ring. */
focusRing: string; focusRing: string;
/** Meter/LED zone semantics: healthy level. */
zoneGood: string;
/** Meter/LED zone semantics: getting hot. */
zoneWarn: string;
/** Meter/LED zone semantics: too hot / clipping. */
zoneHot: string;
fontMono: string; fontMono: string;
fontUI: string; fontUI: string;
} }
@ -39,6 +45,9 @@ export const darkTheme: KnobTheme = {
label: 'rgba(255,255,255,0.45)', label: 'rgba(255,255,255,0.45)',
ticks: 'rgba(255,255,255,0.18)', ticks: 'rgba(255,255,255,0.18)',
focusRing: 'rgba(130,180,255,0.65)', focusRing: 'rgba(130,180,255,0.65)',
zoneGood: '#3df2ad',
zoneWarn: '#ffd23e',
zoneHot: '#ff4d6b',
fontMono: MONO_FONT, fontMono: MONO_FONT,
fontUI: UI_FONT, fontUI: UI_FONT,
}; };
@ -50,6 +59,9 @@ export const lightTheme: KnobTheme = {
label: 'rgba(0,0,0,0.5)', label: 'rgba(0,0,0,0.5)',
ticks: 'rgba(0,0,0,0.28)', ticks: 'rgba(0,0,0,0.28)',
focusRing: 'rgba(0,110,220,0.55)', focusRing: 'rgba(0,110,220,0.55)',
zoneGood: '#0fa571',
zoneWarn: '#c99000',
zoneHot: '#d92546',
fontMono: MONO_FONT, fontMono: MONO_FONT,
fontUI: UI_FONT, fontUI: UI_FONT,
}; };

View file

@ -1,7 +1,18 @@
import type { Taper } from './math'; import type { Taper } from './math';
/** What produced a value change. */ /**
export type ChangeSource = 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'; * What produced a value change.
*
* Gesture contract which sources bracket changes with onChangeStart/End:
* | source | onChangeStart | onChange (per step) | onChangeEnd |
* | 'drag' | yes | yes | yes (pointer up / Escape / disabled) |
* | 'wheel' | yes (burst) | yes | yes (250 ms settle) |
* | 'keyboard' | yes (burst) | yes | yes (key up / blur) |
* | 'reset' | yes | yes | yes |
* | 'edit' | yes | yes | yes (type-in editor commit) |
* | 'api' | no | yes | no (imperative setValue) |
*/
export type ChangeSource = 'drag' | 'wheel' | 'keyboard' | 'reset' | 'edit' | 'api';
export interface ChangeMeta { export interface ChangeMeta {
source: ChangeSource; source: ChangeSource;
@ -80,6 +91,14 @@ export interface KnobCoreProps {
* `prefers-reduced-motion` disables it. Default: false. * `prefers-reduced-motion` disables it. Default: false.
*/ */
animateChanges?: boolean | { duration?: number }; animateChanges?: boolean | { duration?: number };
/**
* When to emit onChange for pointer drags. 'live' (default) fires per
* movement; 'release' previews the drag visually but fires onChange exactly
* once when the pointer is released for network-expensive or
* side-effectful parameters. Wheel, keyboard and type-in stay immediate
* (deliberate acts), and Escape discards the preview without committing.
*/
commitMode?: 'live' | 'release';
/** Drag behaviour. Default: 'rotary'. */ /** Drag behaviour. Default: 'rotary'. */
interaction?: InteractionMode; interaction?: InteractionMode;
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */ /** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */

View file

@ -48,8 +48,10 @@ export interface UseKnobResult extends KnobState {
'data-focus-visible'?: string; 'data-focus-visible'?: string;
style: React.CSSProperties; style: React.CSSProperties;
}; };
/** Imperatively set the value (snapped + clamped). */ /** Imperatively set the value (snapped + clamped). Source: 'api' — no Start/End. */
setValue: (value: number) => void; setValue: (value: number) => void;
/** Commit a value as an edit gesture: fires onChangeStart → onChange → onChangeEnd with source 'edit'. */
commitValue: (value: number) => void;
} }
interface DragSession { interface DragSession {
@ -94,6 +96,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
detentSize = 0.025, detentSize = 0.025,
wrap = false, wrap = false,
animateChanges = false, animateChanges = false,
commitMode = 'live',
fineMultiplier = 0.1, fineMultiplier = 0.1,
angleOffset = 225, angleOffset = 225,
angleRange = 270, angleRange = 270,
@ -191,10 +194,33 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
detents?.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1)), detents?.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1)),
[detents, taper, min, max], [detents, taper, min, max],
); );
// commitMode 'release': drags update this visual preview; onChange fires
// once at pointer-up with the final value.
const [previewValue, setPreviewValue] = React.useState<number | null>(null);
const previewRef = React.useRef<number | null>(null);
previewRef.current = previewValue;
const setFromDrag = React.useCallback( const setFromDrag = React.useCallback(
(n: number) => (n: number) => {
setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n, 'drag'), const nn = detentsN ? applyDetents(n, detentsN, detentSize) : n;
[setFromNormalized, detentsN, detentSize], if (commitMode === 'release') {
setPreviewValue(constrain(taper.fromNormalized(wrapN(nn), min, max)));
} else {
setFromNormalized(nn, 'drag');
}
},
[setFromNormalized, detentsN, detentSize, commitMode, constrain, taper, wrapN, min, max],
);
/** Commit a value from a type-in editor: full edit-gesture bracketing. */
const commitValue = React.useCallback(
(v: number) => {
const target = constrain(v);
onChangeStartRef.current?.(valueRef.current, { source: 'edit' });
emit(target, 'edit');
onChangeEndRef.current?.(target, { source: 'edit' });
},
[constrain, emit],
); );
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -334,12 +360,23 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
[interactive, applyPointer], [interactive, applyPointer],
); );
const endDrag = React.useCallback((e: React.PointerEvent) => { const endDrag = React.useCallback(
(e: React.PointerEvent) => {
if (!session.current || e.pointerId !== session.current.pointerId) return; if (!session.current || e.pointerId !== session.current.pointerId) return;
session.current = null; session.current = null;
setIsDragging(false); setIsDragging(false);
const pending = previewRef.current;
if (pending !== null) {
// release-mode: commit exactly once, now.
setPreviewValue(null);
emit(pending, 'drag');
onChangeEndRef.current?.(pending, { source: 'drag' });
} else {
onChangeEndRef.current?.(valueRef.current, { source: 'drag' }); onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
}, []); }
},
[emit],
);
// If the control becomes disabled/readOnly mid-gesture, cancel the drag — // If the control becomes disabled/readOnly mid-gesture, cancel the drag —
// otherwise it would keep emitting while rendered as disabled. // otherwise it would keep emitting while rendered as disabled.
@ -347,6 +384,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
if (interactive || !session.current) return; if (interactive || !session.current) return;
session.current = null; session.current = null;
setIsDragging(false); setIsDragging(false);
setPreviewValue(null);
onChangeEndRef.current?.(valueRef.current, { source: 'drag' }); onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
}, [interactive]); }, [interactive]);
@ -473,7 +511,12 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
e.preventDefault(); e.preventDefault();
session.current = null; session.current = null;
setIsDragging(false); setIsDragging(false);
if (previewRef.current !== null) {
// release-mode: nothing was committed — just discard the preview.
setPreviewValue(null);
} else {
emit(s.startValue, 'drag'); emit(s.startValue, 'drag');
}
onChangeEndRef.current?.(s.startValue, { source: 'drag' }); onChangeEndRef.current?.(s.startValue, { source: 'drag' });
return; return;
} }
@ -594,7 +637,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
}; };
}, [value, animMs]); }, [value, animMs]);
const shownValue = animMs ? displayValue : value; const shownValue = previewValue ?? (animMs ? displayValue : value);
const normalized = clamp(taper.toNormalized(shownValue, min, max), 0, 1); const normalized = clamp(taper.toNormalized(shownValue, min, max), 0, 1);
const angle = angleFromNormalized(normalized, angleOffset, angleRange); const angle = angleFromNormalized(normalized, angleOffset, angleRange);
const originNormalized = const originNormalized =
@ -603,7 +646,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
: clamp(taper.toNormalized(clamp(origin, min, max), min, max), 0, 1); : clamp(taper.toNormalized(clamp(origin, min, max), min, max), 0, 1);
return { return {
value, value: previewValue ?? value,
normalized, normalized,
originNormalized, originNormalized,
angle, angle,
@ -616,6 +659,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
angleRange, angleRange,
ref, ref,
setValue: emit, setValue: emit,
commitValue,
bind: { bind: {
ref, ref,
onPointerDown, onPointerDown,
@ -631,7 +675,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
tabIndex: disabled ? -1 : 0, tabIndex: disabled ? -1 : 0,
'aria-valuemin': min, 'aria-valuemin': min,
'aria-valuemax': max, 'aria-valuemax': max,
'aria-valuenow': value, 'aria-valuenow': previewValue ?? value,
'aria-orientation': 'aria-orientation':
interaction === 'track-vertical' interaction === 'track-vertical'
? ('vertical' as const) ? ('vertical' as const)

View file

@ -67,6 +67,10 @@ export { Fader, type FaderProps } from './skins/Fader';
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader'; export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob'; export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { Meter, type MeterProps } from './skins/Meter'; export { Meter, type MeterProps } from './skins/Meter';
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
export { PushButton, type PushButtonProps } from './skins/PushButton'; export { PushButton, type PushButtonProps } from './skins/PushButton';
export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
export { SegmentSwitch, type SegmentSwitchProps } from './skins/SegmentSwitch';
export { ScrubField, type ScrubFieldProps } from './skins/ScrubField';
export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad'; export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter'; export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';

View file

@ -28,6 +28,12 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
format?: (value: number) => string; format?: (value: number) => string;
textColor?: string; textColor?: string;
labelColor?: string; labelColor?: string;
/**
* Where the value readout sits: 'top' (default), 'end' (after the track
* inline to the right for horizontal faders, below for vertical), or
* 'none'.
*/
valuePosition?: 'top' | 'end' | 'none';
/** Click the readout to type an exact value. */ /** Click the readout to type an exact value. */
editable?: boolean; editable?: boolean;
/** Custom parser for typed input. */ /** Custom parser for typed input. */
@ -57,6 +63,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
format, format,
textColor, textColor,
labelColor, labelColor,
valuePosition = 'top',
editable = false, editable = false,
parseValue, parseValue,
name, name,
@ -78,7 +85,11 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
// Align pointer mapping with the handle's travel so grabbing the cap // Align pointer mapping with the handle's travel so grabbing the cap
// doesn't jump the value. // doesn't jump the value.
trackInset: capMain / 2, trackInset: capMain / 2,
getAriaValueText:
core.getAriaValueText ??
(format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined),
}); });
const showReadout = showValue && valuePosition !== 'none';
const id = React.useId(); const id = React.useId();
const capCross = breadth * 0.62; const capCross = breadth * 0.62;
@ -120,28 +131,17 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
); );
} }
return ( const atEnd = valuePosition === 'end';
<div const readout = showReadout && (
className={className}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
// Fixed width so a widening value readout never reflows the layout;
// overflowing text spills symmetrically via the centered flex rows.
width: w,
...style,
}}
>
{showValue && (
<div <div
style={{ style={{
width: '100%', width: atEnd ? undefined : '100%',
minWidth: atEnd ? '5.5ch' : undefined,
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: atEnd ? 'flex-start' : 'center',
position: 'relative', position: 'relative',
minHeight: '1.4em', minHeight: '1.4em',
alignItems: 'center',
}} }}
> >
{editing ? ( {editing ? (
@ -149,11 +149,11 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
initial={String(knob.value)} initial={String(knob.value)}
onCommit={raw => { onCommit={raw => {
const parsed = (parseValue ?? defaultParseValue)(raw); const parsed = (parseValue ?? defaultParseValue)(raw);
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed); if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
setEditing(false); setEditing(false);
}} }}
onCancel={() => setEditing(false)} onCancel={() => setEditing(false)}
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }} style={{ position: 'static', transform: 'none', width: atEnd ? '9ch' : '100%', fontSize: 12 }}
/> />
) : ( ) : (
<span <span
@ -173,7 +173,9 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
</span> </span>
)} )}
</div> </div>
)} );
const control = (
<div <div
{...knob.bind} {...knob.bind}
ref={node => { ref={node => {
@ -183,6 +185,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
}} }}
style={{ style={{
...knob.bind.style, ...knob.bind.style,
opacity: undefined, // the outer wrapper owns disabled dimming
width: w, width: w,
height: h, height: h,
display: 'inline-flex', display: 'inline-flex',
@ -261,6 +264,36 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
</svg> </svg>
{name && <input type="hidden" name={name} value={knob.value} readOnly />} {name && <input type="hidden" name={name} value={knob.value} readOnly />}
</div> </div>
);
return (
<div
className={className}
data-disabled={core.disabled ? '' : undefined}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
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,
opacity: core.disabled ? 0.45 : undefined,
...style,
}}
>
{valuePosition === 'top' && readout}
{atEnd && !vertical ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{control}
{readout}
</div>
) : (
<>
{control}
{atEnd && readout}
</>
)}
{label && ( {label && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}> <div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<span <span

View file

@ -51,6 +51,7 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
style={style} style={style}
focusRing={focusRing ?? theme.focusRing} focusRing={focusRing ?? theme.focusRing}
{...core} {...core}
getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}
> >
<Arc <Arc
thickness={t} thickness={t}

View file

@ -62,7 +62,12 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
...core ...core
}, ref) { }, ref) {
const theme = useKnobTheme(); const theme = useKnobTheme();
const knob = useKnob(core); const knob = useKnob({
...core,
getAriaValueText:
core.getAriaValueText ??
(format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined),
});
const [editing, setEditing] = React.useState(false); const [editing, setEditing] = React.useState(false);
const ring = focusRing ?? theme.focusRing; const ring = focusRing ?? theme.focusRing;
@ -82,12 +87,14 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
return ( return (
<div <div
className={className} className={className}
data-disabled={core.disabled ? '' : undefined}
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 4,
width: size, width: size,
opacity: core.disabled ? 0.45 : undefined,
...style, ...style,
}} }}
> >
@ -108,6 +115,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
} }
style={{ style={{
...knob.bind.style, ...knob.bind.style,
opacity: undefined, // the outer wrapper owns disabled dimming
position: 'relative', position: 'relative',
width: size, width: size,
height: size, height: size,
@ -133,7 +141,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
initial={String(knob.value)} initial={String(knob.value)}
onCommit={raw => { onCommit={raw => {
const parsed = (parseValue ?? defaultParseValue)(raw); const parsed = (parseValue ?? defaultParseValue)(raw);
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed); if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
closeEditor(); closeEditor();
}} }}
onCancel={closeEditor} onCancel={closeEditor}

View file

@ -0,0 +1,184 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface IndicatorLampProps {
/** Controlled on-state. Pair with `onChange`. */
on?: boolean;
defaultOn?: boolean;
onChange?: (on: boolean) => void;
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
mode?: 'toggle' | 'momentary';
/** Lamp color when lit. */
color?: string;
/** Lamp diameter in px. Default: 12. */
size?: number;
shape?: 'round' | 'square';
/** Pulse while lit — armed/recording states. Respects prefers-reduced-motion. */
blink?: boolean;
/** Pure status light: not pressable, not focusable, no switch semantics. */
readOnly?: boolean;
/** Caption beside the lamp. */
label?: string;
labelPosition?: 'right' | 'left';
disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */
name?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* A pressable LED lamp the hardware-real toggle for dense headers and
* checkbox duty (`● CUE ● MUTE`). Renders `role="switch"`.
*/
export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampProps>(
function IndicatorLamp(
{
on,
defaultOn = false,
onChange,
mode = 'toggle',
color,
size = 12,
shape = 'round',
blink = false,
readOnly = false,
label,
labelPosition = 'right',
disabled = false,
name,
className,
style,
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#3df2ad';
const isControlled = on !== undefined;
const [internal, setInternal] = React.useState(defaultOn);
const [focusVisible, setFocusVisible] = React.useState(false);
const lit = isControlled ? (on as boolean) : internal;
const ledRef = React.useRef<HTMLSpanElement>(null);
const set = (next: boolean) => {
if (next === lit) return;
if (!isControlled) setInternal(next);
onChange?.(next);
};
// Blink via the Web Animations API — no stylesheet injection needed.
React.useEffect(() => {
const el = ledRef.current;
if (!el || !blink || !lit) return;
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
return;
const anim = el.animate([{ opacity: 1 }, { opacity: 0.25 }], {
duration: 550,
iterations: Infinity,
direction: 'alternate',
easing: 'ease-in-out',
});
return () => anim.cancel();
}, [blink, lit]);
const handlers = readOnly
? {}
: mode === 'momentary'
? {
onPointerDown: (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
set(true);
},
onPointerUp: () => set(false),
onPointerCancel: () => set(false),
onKeyDown: (e: React.KeyboardEvent) => {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) set(true);
},
onKeyUp: (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') set(false);
},
}
: { onClick: () => set(!lit) };
const lamp = (
<span
ref={ledRef}
aria-hidden="true"
style={{
width: size,
height: size,
flex: 'none',
borderRadius: shape === 'round' ? '50%' : Math.max(2, size * 0.2),
background: lit ? accent : 'rgba(255,255,255,0.1)',
border: '1px solid rgba(0,0,0,0.55)',
boxShadow: lit
? `0 0 ${size * 0.7}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',
}}
/>
);
return (
<button
ref={ref}
type="button"
role={readOnly ? 'status' : 'switch'}
aria-checked={readOnly ? undefined : lit}
aria-label={aria['aria-label'] ?? label}
disabled={disabled}
tabIndex={readOnly ? -1 : undefined}
data-on={lit ? '' : undefined}
data-focus-visible={focusVisible ? '' : undefined}
className={className}
{...handlers}
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(5, size * 0.5),
padding: 3,
background: 'none',
border: 'none',
borderRadius: 6,
cursor: disabled ? 'not-allowed' : readOnly ? 'default' : 'pointer',
opacity: disabled ? 0.45 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
touchAction: 'manipulation',
outline: 'none',
boxShadow: focusVisible ? `0 0 0 2px ${theme.focusRing}` : undefined,
...style,
}}
>
{lamp}
{label && (
<span
style={{
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.9),
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: lit ? theme.text : theme.label,
whiteSpace: 'nowrap',
transition: 'color 80ms',
}}
>
{label}
</span>
)}
{name && <input type="hidden" name={name} value={lit ? 'on' : 'off'} readOnly />}
</button>
);
},
);

View file

@ -38,8 +38,10 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
showValue?: boolean; showValue?: boolean;
/** Digit cells in the readout. Default: 4. */ /** Digit cells in the readout. Default: 4. */
digits?: number; digits?: number;
/** Decimals in the readout (defaults to the control's decimals, capped at 1). */ /** Decimals in the readout. Defaults to the control's decimals — size `digits` to fit. */
displayDecimals?: number; displayDecimals?: number;
/** Unit suffix beside the readout, e.g. "dB". */
unit?: string;
labelColor?: string; labelColor?: string;
/** Click the readout to type an exact value. */ /** Click the readout to type an exact value. */
editable?: boolean; editable?: boolean;
@ -67,6 +69,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
showValue = true, showValue = true,
digits = 4, digits = 4,
displayDecimals, displayDecimals,
unit,
labelColor, labelColor,
editable = false, editable = false,
parseValue, parseValue,
@ -77,7 +80,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
}, ref) { }, ref) {
const [editing, setEditing] = React.useState(false); const [editing, setEditing] = React.useState(false);
const theme = useKnobTheme(); const theme = useKnobTheme();
const color = colorProp ?? theme.accent ?? '#3df2ad'; const color = colorProp ?? theme.accent;
const ring = focusRing ?? theme.focusRing; const ring = focusRing ?? theme.focusRing;
const vertical = orientation === 'vertical'; const vertical = orientation === 'vertical';
const pad = 5; const pad = 5;
@ -85,16 +88,22 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
...core, ...core,
interaction: vertical ? 'track-vertical' : 'track-horizontal', interaction: vertical ? 'track-vertical' : 'track-horizontal',
trackInset: pad, trackInset: pad,
getAriaValueText: core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined),
}); });
const id = React.useId(); const id = React.useId();
const glowId = `${id}-glow`; const glowId = `${id}-glow`;
const w = vertical ? breadth : length; const w = vertical ? breadth : length;
const h = vertical ? length : breadth; const h = vertical ? length : breadth;
const zoneList: readonly LEDFaderZone[] = React.useMemo( const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]), if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
[zones, color], 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 zoneFor = (t: number): string => const zoneFor = (t: number): string =>
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
@ -142,12 +151,14 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
return ( return (
<div <div
className={className} className={className}
data-disabled={core.disabled ? '' : undefined}
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
width: w, width: w,
opacity: core.disabled ? 0.45 : undefined,
...style, ...style,
}} }}
> >
@ -171,22 +182,29 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
initial={String(knob.value)} initial={String(knob.value)}
onCommit={raw => { onCommit={raw => {
const parsed = (parseValue ?? defaultParseValue)(raw); const parsed = (parseValue ?? defaultParseValue)(raw);
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed); if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
setEditing(false); setEditing(false);
}} }}
onCancel={() => setEditing(false)} onCancel={() => setEditing(false)}
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }} style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
/> />
) : ( ) : (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4 }}>
<SegmentDisplay <SegmentDisplay
value={knob.value} value={knob.value}
digits={digits} digits={digits}
decimals={displayDecimals ?? Math.min(knob.decimals, 1)} decimals={displayDecimals ?? knob.decimals}
height={12} height={12}
color={zoneFor(knob.normalized)} color={zoneFor(knob.normalized)}
background="none" background="none"
ghostOpacity={0.07} ghostOpacity={0.07}
/> />
{unit && (
<span style={{ fontFamily: theme.fontMono, fontSize: 10, color: theme.label, whiteSpace: 'nowrap' }}>
{unit}
</span>
)}
</span>
)} )}
</div> </div>
)} )}
@ -199,6 +217,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
}} }}
style={{ style={{
...knob.bind.style, ...knob.bind.style,
opacity: undefined, // the outer wrapper owns disabled dimming
width: w, width: w,
height: h, height: h,
display: 'inline-flex', display: 'inline-flex',

View file

@ -17,7 +17,7 @@ export interface LEDKnobProps extends SkinProps {
segments?: number; segments?: number;
/** Digit cells in the center readout. */ /** Digit cells in the center readout. */
digits?: number; digits?: number;
/** Decimals shown in the readout (defaults to the knob's decimals, capped at 2). */ /** Decimals shown in the readout. Defaults to the knob's decimals — make sure `digits` has room. */
displayDecimals?: number; displayDecimals?: number;
labelColor?: string; labelColor?: string;
faceColor?: string; faceColor?: string;
@ -92,6 +92,7 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
labelColor, labelColor,
faceColor = '#0b0d0e', faceColor = '#0b0d0e',
label, label,
unit,
focusRing, focusRing,
className, className,
style, style,
@ -102,7 +103,15 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
const id = React.useId(); const id = React.useId();
const glowId = `${id}-glow`; const glowId = `${id}-glow`;
return ( return (
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}> <Knob
ref={ref}
size={size}
className={className}
style={style}
focusRing={focusRing}
{...core}
getAriaValueText={core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined)}
>
{ctx => ( {ctx => (
<> <>
<defs> <defs>
@ -120,9 +129,22 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
<CenterDisplay <CenterDisplay
color={color} color={color}
digits={digits} digits={digits}
decimals={displayDecimals ?? Math.min(ctx.decimals, 2)} decimals={displayDecimals ?? ctx.decimals}
glowId={glowId} glowId={glowId}
/> />
{unit && (
<text
x={ctx.center}
y={ctx.center + size * 0.2}
textAnchor="middle"
fontSize={size * 0.08}
fill={theme.label}
fontFamily={theme.fontMono}
style={{ pointerEvents: 'none' }}
>
{unit}
</text>
)}
{label && ( {label && (
<KnobLabel <KnobLabel
color={labelColor ?? theme.label} color={labelColor ?? theme.label}

View file

@ -61,7 +61,7 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color); const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
return ( return (
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}> <Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
<defs> <defs>
<linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1"> <linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1">
<stop offset="0" stopColor={rimStops[0]} /> <stop offset="0" stopColor={rimStops[0]} />

View file

@ -6,21 +6,24 @@ import type { LEDFaderZone } from './LEDFader';
import { SegmentDisplay } from '../digital/SegmentDisplay'; import { SegmentDisplay } from '../digital/SegmentDisplay';
export interface MeterProps { export interface MeterProps {
/** Level to display. The meter is read-only — always controlled. */ /**
value: number; * Level to display a single number, or an array for a multi-channel
* meter (e.g. `[l, r]` for stereo). The meter is read-only.
*/
value: number | readonly number[];
min?: number; min?: number;
max?: number; max?: number;
taper?: Taper; taper?: Taper;
orientation?: 'vertical' | 'horizontal'; orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */ /** Travel length in px. Default: 160. */
length?: number; length?: number;
/** Cross-axis size in px. Default: 16. */ /** Cross-axis size in px PER CHANNEL. Minimum useful: ~6. Default: 16. */
breadth?: number; breadth?: number;
/** Number of LED segments. Default: 28. */ /** Number of LED segments. Default: 28. */
segments?: number; segments?: number;
/** LED color when no zones are given. */ /** LED color when no zones are given. Defaults to the theme's zone colors. */
color?: string; color?: string;
/** Meter zones, e.g. green / yellow / red by normalized position. */ /** Meter zones by normalized position. Defaults to theme zoneGood/Warn/Hot. */
zones?: readonly LEDFaderZone[]; zones?: readonly LEDFaderZone[];
/** Opacity of unlit segments. Default: 0.1. */ /** Opacity of unlit segments. Default: 0.1. */
offOpacity?: number; offOpacity?: number;
@ -31,40 +34,45 @@ export interface MeterProps {
peakHold?: number | false; peakHold?: number | false;
/** Peak indicator color. Defaults to the peak's zone color. */ /** Peak indicator color. Defaults to the peak's zone color. */
peakColor?: string; peakColor?: string;
/** Dedicated clip LED at the hot end of the meter. Default: true. */ /** Dedicated clip LED at the hot end (shared across channels). Default: true. */
showClip?: boolean; showClip?: boolean;
/** Value at/above which the clip LED lights. Default: `max`. */ /** Value at/above which the clip LED lights. Default: `max`. */
clipThreshold?: number; clipThreshold?: number;
/** /**
* Signal used for clip detection, when it differs from the displayed * Signal(s) used for clip detection when different from the displayed
* level e.g. show RMS on the bar but clip on sample peaks. Defaults * level e.g. show RMS on the bars but clip on sample peaks. Same shape
* to `value`. * as `value`. Defaults to `value`.
*/ */
clipValue?: number; clipValue?: number | readonly number[];
/** /**
* How long the clip LED stays lit in ms, or 'latch' to stay lit until * How long the clip LED stays lit in ms, or 'latch' to stay lit until
* clicked. Default: 1500. * clicked. Default: 1500.
*/ */
clipHold?: number | 'latch'; clipHold?: number | 'latch';
clipColor?: string; clipColor?: string;
/** Fired when the signal first crosses the clip threshold. */ /** Fired when any channel first crosses the clip threshold. */
onClip?: (value: number) => void; onClip?: (value: number) => void;
/** Panel color behind the LEDs. */ /** Panel color behind the LEDs. */
faceColor?: string; faceColor?: string;
label?: string; label?: string;
/** Show a seven-segment readout. Default: false. */ /** Show a seven-segment readout (of the hottest channel). Default: false. */
showValue?: boolean; showValue?: boolean;
digits?: number; digits?: number;
displayDecimals?: number; displayDecimals?: number;
/** Unit suffix beside the readout, e.g. "dB". */
unit?: string;
labelColor?: string; labelColor?: string;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
'aria-label'?: string; 'aria-label'?: string;
} }
const asArray = (v: number | readonly number[]): readonly number[] =>
Array.isArray(v) ? v : [v as number];
/** /**
* Read-only LED level meter with peak hold the display-side sibling of * Read-only LED level meter with peak hold and a latching clip LED pass an
* LEDFader for VU/output metering. * array of values for stereo/multi-channel bars sharing one clip indicator.
*/ */
export const Meter: React.FC<MeterProps> = ({ export const Meter: React.FC<MeterProps> = ({
value, value,
@ -75,7 +83,7 @@ export const Meter: React.FC<MeterProps> = ({
length = 160, length = 160,
breadth = 16, breadth = 16,
segments = 28, segments = 28,
color = '#3df2ad', color,
zones, zones,
offOpacity = 0.1, offOpacity = 0.1,
peakHold = 1200, peakHold = 1200,
@ -91,6 +99,7 @@ export const Meter: React.FC<MeterProps> = ({
showValue = false, showValue = false,
digits = 4, digits = 4,
displayDecimals = 1, displayDecimals = 1,
unit,
labelColor, labelColor,
className, className,
style, style,
@ -98,22 +107,27 @@ export const Meter: React.FC<MeterProps> = ({
}) => { }) => {
const theme = useKnobTheme(); const theme = useKnobTheme();
const vertical = orientation === 'vertical'; const vertical = orientation === 'vertical';
const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1); const values = asArray(value);
const channels = values.length;
const ns = values.map(v => clamp(taper.toNormalized(clamp(v, min, max), min, max), 0, 1));
// Peak: track the highest recent level, fall back after `peakHold` ms. // Peaks: per channel, falling back together after `peakHold` ms of no rise.
const [peak, setPeak] = React.useState(n); const [peaks, setPeaks] = React.useState<readonly number[]>(ns);
React.useEffect(() => { React.useEffect(() => {
if (peakHold === false) return; if (peakHold === false) return;
if (n >= peak) { setPeaks(prev => {
setPeak(n); const rose = ns.map((n, i) => n >= (prev[i] ?? 0));
return; if (rose.every(Boolean) || prev.length !== ns.length) return ns.map((n, i) => Math.max(n, prev[i] ?? 0));
} return prev.map((p, i) => Math.max(p, ns[i] ?? 0));
const t = setTimeout(() => setPeak(n), peakHold); });
const t = setTimeout(() => setPeaks(ns), peakHold);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [n, peak, peakHold]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(ns), peakHold]);
// Clip: latch a dedicated LED when the raw (unclamped) signal reaches the // Clip: latch when any channel's raw (unclamped) signal reaches threshold.
// threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode. const clipSignals = clipValue !== undefined ? asArray(clipValue) : values;
const hottest = Math.max(...clipSignals);
const [clipped, setClipped] = React.useState(false); const [clipped, setClipped] = React.useState(false);
const clipTimer = React.useRef<ReturnType<typeof setTimeout>>(); const clipTimer = React.useRef<ReturnType<typeof setTimeout>>();
const wasOver = React.useRef(false); const wasOver = React.useRef(false);
@ -121,10 +135,9 @@ export const Meter: React.FC<MeterProps> = ({
onClipRef.current = onClip; onClipRef.current = onClip;
React.useEffect(() => { React.useEffect(() => {
if (!showClip) return; if (!showClip) return;
const signal = clipValue ?? value; const over = hottest >= (clipThreshold ?? max) - 1e-9;
const over = signal >= (clipThreshold ?? max) - 1e-9;
if (over) { if (over) {
if (!wasOver.current) onClipRef.current?.(signal); if (!wasOver.current) onClipRef.current?.(hottest);
setClipped(true); setClipped(true);
if (clipHold !== 'latch') { if (clipHold !== 'latch') {
clearTimeout(clipTimer.current); clearTimeout(clipTimer.current);
@ -132,51 +145,76 @@ export const Meter: React.FC<MeterProps> = ({
} }
} }
wasOver.current = over; wasOver.current = over;
}, [value, clipValue, showClip, clipThreshold, max, clipHold]); }, [hottest, showClip, clipThreshold, max, clipHold]);
React.useEffect(() => () => clearTimeout(clipTimer.current), []); React.useEffect(() => () => clearTimeout(clipTimer.current), []);
const pad = 4; const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
const w = vertical ? breadth : length; if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
const h = vertical ? length : breadth; if (color) return [{ upTo: 1, color }];
const zoneList: readonly LEDFaderZone[] = React.useMemo( return [
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]), { upTo: 0.72, color: theme.zoneGood },
[zones, color], { upTo: 0.9, color: theme.zoneWarn },
); { upTo: 1, color: theme.zoneHot },
];
}, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]);
const zoneFor = (t: number): string => const zoneFor = (t: number): string =>
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
const lit = Math.round(n * segments); // Geometry — clamped so thin meters degrade gracefully instead of
const peakIdx = // producing negative SVG rects. pad shrinks below breadth 14.
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1; const pad = breadth < 14 ? 2 : 4;
// Reserve room at the hot end for the clip LED (7px LED + 3px gap). const cross = Math.max(1, breadth - pad * 2);
const chGap = channels > 1 ? 3 : 0;
const along = length; // travel axis
const across = channels * breadth + (channels - 1) * chGap;
const w = vertical ? across : along;
const h = vertical ? along : across;
const clipLed = 7; const clipLed = 7;
const clipSpan = showClip ? clipLed + 3 : 0; const clipSpan = showClip ? clipLed + 3 : 0;
const slot = (length - pad * 2 - clipSpan) / segments; const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments);
const gap = Math.min(2.5, slot * 0.35); const gap = Math.min(2.5, slot * 0.35);
const cross = breadth - pad * 2; const segSize = Math.max(0.5, slot - gap);
const leds: React.ReactNode[] = []; const bars: React.ReactNode[] = [];
for (let c = 0; c < channels; c++) {
const offset = c * (breadth + chGap) + pad;
const lit = Math.round((ns[c] ?? 0) * segments);
const peakIdx =
peakHold !== false && (peaks[c] ?? 0) > 0
? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1)
: -1;
for (let i = 0; i < segments; i++) { for (let i = 0; i < segments; i++) {
const zone = zoneFor((i + 1) / segments); const zone = zoneFor((i + 1) / segments);
const isPeak = i === peakIdx && i >= lit; const isPeak = i === peakIdx && i >= lit;
const on = i < lit || isPeak; const on = i < lit || isPeak;
const fill = isPeak ? (peakColor ?? zone) : zone; const fill = isPeak ? (peakColor ?? zone) : zone;
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity }; const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
leds.push( bars.push(
vertical ? ( vertical ? (
<rect <rect
key={i} key={`${c}-${i}`}
x={pad} x={offset}
y={length - pad - (i + 1) * slot + gap / 2} y={along - pad - (i + 1) * slot + gap / 2}
width={cross} width={cross}
height={slot - gap} height={segSize}
{...common} {...common}
/> />
) : ( ) : (
<rect key={i} x={pad + i * slot + gap / 2} y={pad} width={slot - gap} height={cross} {...common} /> <rect
key={`${c}-${i}`}
x={pad + i * slot + gap / 2}
y={offset}
width={segSize}
height={cross}
{...common}
/>
), ),
); );
} }
}
const hottestShown = Math.max(...values.map(v => clamp(v, min, max)));
return ( return (
<div <div
@ -184,7 +222,7 @@ export const Meter: React.FC<MeterProps> = ({
role="meter" role="meter"
aria-valuemin={min} aria-valuemin={min}
aria-valuemax={max} aria-valuemax={max}
aria-valuenow={clamp(value, min, max)} aria-valuenow={hottestShown}
aria-label={aria['aria-label']} aria-label={aria['aria-label']}
data-clipped={clipped ? '' : undefined} data-clipped={clipped ? '' : undefined}
style={{ style={{
@ -197,28 +235,33 @@ export const Meter: React.FC<MeterProps> = ({
}} }}
> >
{showValue && ( {showValue && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}> <div style={{ width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: 4 }}>
<SegmentDisplay <SegmentDisplay
value={clamp(value, min, max)} value={hottestShown}
digits={digits} digits={digits}
decimals={displayDecimals} decimals={displayDecimals}
height={11} height={11}
color={zoneFor(n)} color={zoneFor(Math.max(...ns))}
background="none" background="none"
ghostOpacity={0.06} ghostOpacity={0.06}
/> />
{unit && (
<span style={{ fontFamily: theme.fontMono, fontSize: 10, color: theme.label, whiteSpace: 'nowrap' }}>
{unit}
</span>
)}
</div> </div>
)} )}
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true"> <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} 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)" /> <rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
{leds} {bars}
{showClip && ( {showClip && (
<rect <rect
data-part="clip" data-part="clip"
x={vertical ? pad : length - pad - clipLed} x={vertical ? pad : along - pad - clipLed}
y={vertical ? pad : pad} y={vertical ? pad : pad}
width={vertical ? cross : clipLed} width={vertical ? Math.max(1, across - pad * 2) : clipLed}
height={vertical ? clipLed : cross} height={vertical ? clipLed : Math.max(1, across - pad * 2)}
rx={1.5} rx={1.5}
fill={clipColor} fill={clipColor}
opacity={clipped ? 1 : 0.14} opacity={clipped ? 1 : 0.14}

View file

@ -0,0 +1,124 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
import { Meter, type MeterProps } from './Meter';
export interface MeterBridgeChannel {
label: string;
value: number;
/** Peak signal for clip detection, when different from `value`. */
clipValue?: number;
}
export interface MeterBridgeProps
extends Omit<MeterProps, 'value' | 'clipValue' | 'label' | 'showValue' | 'aria-label'> {
channels: readonly MeterBridgeChannel[];
/** Numeric peak-hold text above each bar. Default: true. */
showPeakText?: boolean;
/** Decimals for the peak text. Default: 1. */
peakTextDecimals?: number;
/** Block caption under the bridge. */
label?: string;
'aria-label'?: string;
}
/**
* A labeled multi-strip meter block n channels, per-channel peak text, one
* shared clip LED. The composition every mixer rebuilds, prebuilt.
*/
export const MeterBridge: React.FC<MeterBridgeProps> = ({
channels,
showPeakText = true,
peakTextDecimals = 1,
label,
min = 0,
max = 100,
breadth = 16,
peakHold = 1200,
className,
style,
...meterProps
}) => {
const theme = useKnobTheme();
const values = channels.map(c => c.value);
const clipValues = channels.map(c => c.clipValue ?? c.value);
const chGap = channels.length > 1 ? 3 : 0;
// Numeric peak-hold per channel for the text row.
const [peaks, setPeaks] = React.useState<readonly number[]>(values);
React.useEffect(() => {
if (peakHold === false) return;
setPeaks(prev =>
prev.length === values.length ? values.map((v, i) => Math.max(v, prev[i] ?? -Infinity)) : values,
);
const t = setTimeout(() => setPeaks(values), peakHold);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(values), peakHold]);
const cell: React.CSSProperties = {
width: breadth,
textAlign: 'center',
fontFamily: theme.fontMono,
whiteSpace: 'nowrap',
overflow: 'visible',
};
return (
<div
className={className}
role="group"
aria-label={meterProps['aria-label' as never] ?? label}
style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4, ...style }}
>
{showPeakText && (
<div style={{ display: 'flex', gap: chGap }}>
{peaks.map((p, i) => (
<span key={i} style={{ ...cell, fontSize: 9, color: theme.label }}>
{Math.max(p, min) <= min ? '—' : Math.min(p, max).toFixed(peakTextDecimals)}
</span>
))}
</div>
)}
<Meter
{...meterProps}
min={min}
max={max}
breadth={breadth}
peakHold={peakHold}
value={values}
clipValue={clipValues}
aria-label={undefined}
/>
<div style={{ display: 'flex', gap: chGap }}>
{channels.map(c => (
<span
key={c.label}
style={{
...cell,
fontSize: 9.5,
fontFamily: theme.fontUI,
letterSpacing: '0.05em',
textTransform: 'uppercase',
color: theme.label,
}}
>
{c.label}
</span>
))}
</div>
{label && (
<span
style={{
fontFamily: theme.fontUI,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: theme.label,
}}
>
{label}
</span>
)}
</div>
);
};

View file

@ -36,7 +36,7 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
const id = React.useId(); const id = React.useId();
const glowId = `${id}-glow`; const glowId = `${id}-glow`;
return ( return (
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}> <Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
<defs> <defs>
<filter <filter
id={glowId} id={glowId}

View file

@ -10,8 +10,12 @@ export interface PushButtonProps {
mode?: 'toggle' | 'momentary'; mode?: 'toggle' | 'momentary';
/** LED / active color. */ /** LED / active color. */
color?: string; color?: string;
/** Show the LED strip. Default: true. */ /**
led?: boolean; * LED style: 'strip' (default, above the caption), 'dot' (small corner
* dot scales down better for icon-only toggles), or false for none.
* `true` is an alias for 'strip'.
*/
led?: boolean | 'strip' | 'dot';
/** Button height in px. Default: 36. */ /** Button height in px. Default: 36. */
size?: number; size?: number;
/** Explicit width; defaults to content. */ /** Explicit width; defaults to content. */
@ -134,7 +138,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
...style, ...style,
}} }}
> >
{led && ( {(led === true || led === 'strip') && (
<span <span
aria-hidden="true" aria-hidden="true"
style={{ style={{
@ -147,6 +151,22 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
}} }}
/> />
)} )}
{led === 'dot' && (
<span
aria-hidden="true"
style={{
position: 'absolute',
top: Math.max(3, size * 0.1),
right: Math.max(3, size * 0.1),
width: Math.max(4, size * 0.14),
height: Math.max(4, size * 0.14),
borderRadius: '50%',
background: on ? accent : 'rgba(255,255,255,0.09)',
boxShadow: on ? `0 0 ${size * 0.25}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms',
}}
/>
)}
{children} {children}
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />} {name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
</button> </button>

View file

@ -42,7 +42,7 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
const bodyR = size / 2 - size * 0.17; const bodyR = size / 2 - size * 0.17;
return ( return (
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}> <Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
<defs> <defs>
<radialGradient id={bodyId} cx="0.38" cy="0.3" r="1"> <radialGradient id={bodyId} cx="0.38" cy="0.3" r="1">
<stop offset="0" stopColor="#3a3b41" /> <stop offset="0" stopColor="#3a3b41" />

View file

@ -0,0 +1,294 @@
import * as React from 'react';
import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
import { useKnobTheme } from '../core/theme';
import type { ChangeMeta, ChangeSource } from '../core/types';
import { defaultParseValue } from '../components/ValueInput';
export interface ScrubFieldProps {
/** Controlled value. Pair with `onChange`. */
value?: number;
defaultValue?: number;
min?: number;
max?: number;
/** Increment per scrub pixel-step / wheel notch / arrow key. Default: 1. */
step?: number;
/** Decimal places of emitted values. Defaults to the precision of `step`. */
decimals?: number;
onChange?: (value: number, meta: ChangeMeta) => void;
onChangeStart?: (value: number, meta: ChangeMeta) => void;
onChangeEnd?: (value: number, meta: ChangeMeta) => void;
/** The scrub handle text — drag it horizontally to adjust. */
label?: string;
/** Unit suffix shown after the field, e.g. "px". */
unit?: string;
/** Horizontal pixels per step while scrubbing. Default: 2. */
scrubSensitivity?: number;
/** Step multiplier while Shift is held (scrub + wheel). Default: 0.1. */
fineMultiplier?: number;
/** Custom parser for typed input. Default accepts "1.2k", commas, units. */
parseValue?: (text: string) => number | null;
/** Input width in ch. Default: 6. */
widthCh?: number;
accentColor?: string;
disabled?: boolean;
/** Render a hidden form input carrying the committed value. */
name?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* A compact numeric field with After-Effects-style label scrubbing: drag the
* label for relative adjustment, click the field to type (draft held until
* Enter/blur, Escape reverts). The spinbox replacement for values where
* typing dominates but scrubbing should still work.
*/
export const ScrubField = React.forwardRef<HTMLInputElement, ScrubFieldProps>(
function ScrubField(
{
value,
defaultValue,
min = -Infinity,
max = Infinity,
step = 1,
decimals,
onChange,
onChangeStart,
onChangeEnd,
label,
unit,
scrubSensitivity = 2,
fineMultiplier = 0.1,
parseValue,
widthCh = 6,
accentColor,
disabled = false,
name,
className,
style,
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = accentColor ?? theme.accent ?? '#4cc2ff';
const dec = decimals ?? (step > 0 ? decimalsFromStep(step) : 3);
const constrain = React.useCallback(
(raw: number) => {
let v = clamp(raw, min, max);
if (step > 0 && Number.isFinite(min)) v = clamp(snapToStep(v, step, min), min, max);
else if (step > 0) v = Math.round(v / step) * step;
return roundTo(v, dec);
},
[min, max, step, dec],
);
const isControlled = value !== undefined;
const [internal, setInternal] = React.useState(() => constrain(defaultValue ?? (Number.isFinite(min) ? min : 0)));
const committed = constrain(isControlled ? (value as number) : internal);
const committedRef = React.useRef(committed);
committedRef.current = committed;
const emit = React.useCallback(
(raw: number, source: ChangeSource) => {
const next = constrain(raw);
if (next === committedRef.current) return;
if (!isControlled) {
committedRef.current = next;
setInternal(next);
}
onChange?.(next, { source });
},
[constrain, isControlled, onChange],
);
// Draft typing: the input owns its text between focus and commit.
const [draft, setDraft] = React.useState<string | null>(null);
// Enter/Escape settle the draft themselves, then blur — the blur handler
// must not commit again off its stale closure.
const skipBlurCommit = React.useRef(false);
const trim = (v: number) => {
const s = v.toFixed(dec);
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
};
const shown = draft ?? trim(committed);
const commitDraft = (text: string) => {
const parsed = (parseValue ?? defaultParseValue)(text);
if (parsed !== null && Number.isFinite(parsed)) {
const target = constrain(parsed);
onChangeStart?.(committedRef.current, { source: 'edit' });
emit(target, 'edit');
onChangeEnd?.(target, { source: 'edit' });
}
setDraft(null);
};
// Label scrubbing.
const scrub = React.useRef<{ pointerId: number; lastX: number; acc: number } | null>(null);
const [scrubbing, setScrubbing] = React.useState(false);
const onScrubDown = (e: React.PointerEvent<HTMLSpanElement>) => {
if (disabled) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
scrub.current = { pointerId: e.pointerId, lastX: e.clientX, acc: 0 };
setScrubbing(true);
onChangeStart?.(committedRef.current, { source: 'drag' });
};
const onScrubMove = (e: React.PointerEvent<HTMLSpanElement>) => {
const s = scrub.current;
if (!s || e.pointerId !== s.pointerId) return;
s.acc += e.clientX - s.lastX;
s.lastX = e.clientX;
const stepsMoved = Math.trunc(s.acc / scrubSensitivity);
if (stepsMoved !== 0) {
s.acc -= stepsMoved * scrubSensitivity;
const mult = e.shiftKey ? fineMultiplier : 1;
emit(committedRef.current + stepsMoved * step * mult, 'drag');
}
};
const onScrubUp = (e: React.PointerEvent<HTMLSpanElement>) => {
if (!scrub.current || e.pointerId !== scrub.current.pointerId) return;
scrub.current = null;
setScrubbing(false);
onChangeEnd?.(committedRef.current, { source: 'drag' });
};
// Wheel over the field (non-passive so the page doesn't scroll).
const wrapRef = React.useRef<HTMLDivElement>(null);
const wheelState = React.useRef({ active: false, timer: undefined as ReturnType<typeof setTimeout> | undefined });
const emitRef = React.useRef(emit);
emitRef.current = emit;
const cbRef = React.useRef({ onChangeStart, onChangeEnd });
cbRef.current = { onChangeStart, onChangeEnd };
React.useEffect(() => {
const el = wrapRef.current;
if (!el || disabled) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
if (!wheelState.current.active) {
wheelState.current.active = true;
cbRef.current.onChangeStart?.(committedRef.current, { source: 'wheel' });
}
const dir = e.deltaY < 0 ? 1 : -1;
const mult = e.shiftKey ? fineMultiplier : 1;
emitRef.current(committedRef.current + dir * step * mult, 'wheel');
clearTimeout(wheelState.current.timer);
wheelState.current.timer = setTimeout(() => {
wheelState.current.active = false;
cbRef.current.onChangeEnd?.(committedRef.current, { source: 'wheel' });
}, 250);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => {
el.removeEventListener('wheel', onWheel);
clearTimeout(wheelState.current.timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled, step, fineMultiplier]);
return (
<div
ref={wrapRef}
className={className}
data-disabled={disabled ? '' : undefined}
data-scrubbing={scrubbing ? '' : undefined}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
opacity: disabled ? 0.45 : 1,
...style,
}}
>
{label && (
<span
onPointerDown={onScrubDown}
onPointerMove={onScrubMove}
onPointerUp={onScrubUp}
onPointerCancel={onScrubUp}
title="Drag to adjust"
style={{
fontFamily: theme.fontUI,
fontSize: 11,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: scrubbing ? accent : theme.label,
cursor: disabled ? 'not-allowed' : 'ew-resize',
userSelect: 'none',
WebkitUserSelect: 'none',
touchAction: 'none',
whiteSpace: 'nowrap',
transition: 'color 80ms',
}}
>
{label}
</span>
)}
<input
ref={ref}
type="text"
inputMode="decimal"
role="spinbutton"
aria-valuemin={Number.isFinite(min) ? min : undefined}
aria-valuemax={Number.isFinite(max) ? max : undefined}
aria-valuenow={committed}
aria-label={aria['aria-label'] ?? label}
disabled={disabled}
value={shown}
onFocus={e => {
setDraft(trim(committed));
e.currentTarget.select();
}}
onChange={e => setDraft(e.target.value)}
onBlur={e => {
if (skipBlurCommit.current) {
skipBlurCommit.current = false;
return;
}
if (draft !== null) commitDraft(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
commitDraft(e.currentTarget.value);
skipBlurCommit.current = true;
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setDraft(null);
skipBlurCommit.current = true;
e.currentTarget.blur();
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const dir = e.key === 'ArrowUp' ? 1 : -1;
const mult = e.shiftKey ? 10 : 1;
emit(committedRef.current + dir * step * mult, 'keyboard');
setDraft(null);
}
}}
style={{
width: `${widthCh}ch`,
background: 'rgba(0,0,0,0.35)',
border: `1px solid ${scrubbing ? accent : 'rgba(255,255,255,0.14)'}`,
borderRadius: 6,
padding: '4px 7px',
fontFamily: theme.fontMono,
fontSize: 12.5,
color: theme.text,
textAlign: 'right',
outline: 'none',
transition: 'border-color 80ms',
}}
/>
{unit && (
<span style={{ fontFamily: theme.fontMono, fontSize: 11, color: theme.label, whiteSpace: 'nowrap' }}>
{unit}
</span>
)}
{name && <input type="hidden" name={name} value={committed} readOnly />}
</div>
);
},
);

View file

@ -0,0 +1,162 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface SegmentSwitchProps {
/** Position labels, e.g. ['LINEAR', 'RADIAL', 'CONIC']. */
options: readonly string[];
/** Controlled selected index. Pair with `onChange`. */
value?: number;
defaultValue?: number;
onChange?: (index: number) => void;
/** Accent for the active cell's LED tick and text. */
color?: string;
/** Control height in px. Default: 30. */
size?: number;
/** Show the LED tick on the active cell. Default: true. */
led?: boolean;
disabled?: boolean;
/** Render a hidden form input carrying the selected option text. */
name?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/**
* Hardware-styled segmented selector the horizontal sibling of
* SteppedKnob. Renders a radiogroup; arrow keys move the selection.
*/
export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps>(
function SegmentSwitch(
{
options,
value,
defaultValue = 0,
onChange,
color,
size = 30,
led = true,
disabled = false,
name,
className,
style,
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
const isControlled = value !== undefined;
const [internal, setInternal] = React.useState(defaultValue);
const active = Math.max(0, Math.min(options.length - 1, isControlled ? (value as number) : internal));
const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const select = (i: number, focus = false) => {
const next = Math.max(0, Math.min(options.length - 1, i));
if (focus) btnRefs.current[next]?.focus();
if (next === active) return;
if (!isControlled) setInternal(next);
onChange?.(next);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
select(active + 1, true);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
select(active - 1, true);
} else if (e.key === 'Home') {
e.preventDefault();
select(0, true);
} else if (e.key === 'End') {
e.preventDefault();
select(options.length - 1, true);
}
};
return (
<div
ref={ref}
role="radiogroup"
aria-label={aria['aria-label']}
data-disabled={disabled ? '' : undefined}
className={className}
onKeyDown={onKeyDown}
style={{
display: 'inline-flex',
height: size,
borderRadius: 8,
border: '1px solid rgba(0,0,0,0.7)',
background: 'linear-gradient(180deg, #16171c, #101116)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06), 0 2px 4px rgba(0,0,0,0.4)',
overflow: 'hidden',
opacity: disabled ? 0.45 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
...style,
}}
>
{options.map((opt, i) => {
const isActive = i === active;
return (
<button
key={opt}
ref={el => {
btnRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={isActive}
tabIndex={isActive ? 0 : -1}
disabled={disabled}
data-active={isActive ? '' : undefined}
onClick={() => select(i)}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
padding: '0 12px',
minWidth: size * 1.6,
border: 'none',
borderRight: i < options.length - 1 ? '1px solid rgba(0,0,0,0.55)' : 'none',
background: isActive
? 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
: 'transparent',
boxShadow: isActive ? 'inset 0 2px 4px rgba(0,0,0,0.55)' : undefined,
color: isActive ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.34),
fontWeight: 600,
letterSpacing: '0.07em',
textTransform: 'uppercase',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'background 80ms, color 80ms',
outline: 'none',
}}
>
{led && (
<span
aria-hidden="true"
style={{
width: '55%',
height: Math.max(2, size * 0.08),
borderRadius: 2,
background: isActive ? accent : 'rgba(255,255,255,0.08)',
boxShadow: isActive ? `0 0 ${size * 0.2}px ${accent}` : undefined,
transition: 'background 80ms, box-shadow 80ms',
}}
/>
)}
{opt}
</button>
);
})}
{name && <input type="hidden" name={name} value={options[active]} readOnly />}
</div>
);
},
);

View file

@ -53,6 +53,7 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
min={min} min={min}
max={max} max={max}
steps={detents} 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 => { {ctx => {
const index = Math.round(ctx.normalized * (detents - 1)); const index = Math.round(ctx.normalized * (detents - 1));

View file

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math'; import { applyDetents, clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
import { useKnobTheme } from '../core/theme'; import { useKnobTheme } from '../core/theme';
import type { ChangeMeta } from '../core/types'; import type { ChangeMeta } from '../core/types';
@ -8,6 +8,14 @@ export interface XYPadAxis {
max?: number; max?: number;
step?: number; step?: number;
defaultValue?: number; defaultValue?: number;
/**
* Flip the axis direction. Y is bottom-up by default (audio convention);
* `invert: true` makes it top-down (screen convention). X is left-right;
* inverted is right-left.
*/
invert?: boolean;
/** Magnetic snap values for this axis while dragging (like knob detents). */
detents?: readonly number[];
} }
export interface XYPadValue { export interface XYPadValue {
@ -32,6 +40,8 @@ export interface XYPadProps {
faceColor?: string; faceColor?: string;
/** Grid divisions per axis (0 disables). Default: 4. */ /** Grid divisions per axis (0 disables). Default: 4. */
grid?: number; grid?: number;
/** Detent capture radius as a fraction of travel. Default: 0.025. */
detentSize?: number;
/** Show the "x · y" readout above the pad. Default: true. */ /** Show the "x · y" readout above the pad. Default: true. */
showValue?: boolean; showValue?: boolean;
formatX?: (v: number) => string; formatX?: (v: number) => string;
@ -66,6 +76,7 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
color, color,
faceColor = 'rgba(255,255,255,0.04)', faceColor = 'rgba(255,255,255,0.04)',
grid = 4, grid = 4,
detentSize = 0.025,
showValue = true, showValue = true,
formatX, formatX,
formatY, formatY,
@ -124,16 +135,39 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
onChange?.(c, { source: 'drag' }); onChange?.(c, { source: 'drag' });
}; };
// Normalized travel <-> value per axis, honoring `invert`.
const valueFromN = (n: number, a: typeof ax) => {
const span = a.max - a.min;
return a.invert ? a.max - n * span : a.min + n * span;
};
const nFromValue = (v: number, a: typeof ax) => {
const span = a.max - a.min;
if (span === 0) return 0;
return clamp(a.invert ? (a.max - v) / span : (v - a.min) / span, 0, 1);
};
const detentsNX = React.useMemo(
() => ax.detents?.map(d => nFromValue(d, ax)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[ax.detents, ax.min, ax.max, ax.invert],
);
const detentsNY = React.useMemo(
() => ay.detents?.map(d => nFromValue(d, ay)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[ay.detents, ay.min, ay.max, ay.invert],
);
const applyPointer = (clientX: number, clientY: number) => { const applyPointer = (clientX: number, clientY: number) => {
const s = session.current; const s = session.current;
if (!s || s.rect.width <= 0 || s.rect.height <= 0) return; if (!s || s.rect.width <= 0 || s.rect.height <= 0) return;
const nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1); let nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
const ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1); let ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1);
emit({ x: ax.min + nx * (ax.max - ax.min), y: ay.min + ny * (ay.max - ay.min) }); if (detentsNX) nx = applyDetents(nx, detentsNX, detentSize);
if (detentsNY) ny = applyDetents(ny, detentsNY, detentSize);
emit({ x: valueFromN(nx, ax), y: valueFromN(ny, ay) });
}; };
const nx = ax.max === ax.min ? 0 : (pos.x - ax.min) / (ax.max - ax.min); const nx = nFromValue(pos.x, ax);
const ny = ay.max === ay.min ? 0 : (pos.y - ay.min) / (ay.max - ay.min); const ny = nFromValue(pos.y, ay);
const hx = nx * width; const hx = nx * width;
const hy = (1 - ny) * height; const hy = (1 - ny) * height;
@ -153,12 +187,14 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
return ( return (
<div <div
className={className} className={className}
data-disabled={disabled ? '' : undefined}
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
width, width,
opacity: disabled ? 0.45 : undefined,
...style, ...style,
}} }}
> >
@ -238,10 +274,11 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
let handled = true; let handled = true;
const p = posRef.current; const p = posRef.current;
switch (e.key) { switch (e.key) {
case 'ArrowLeft': emit({ ...p, x: p.x - stepX }); break; // Arrows follow the VISUAL direction, so inverted axes negate.
case 'ArrowRight': emit({ ...p, x: p.x + stepX }); break; case 'ArrowLeft': emit({ ...p, x: p.x - (ax.invert ? -stepX : stepX) }); break;
case 'ArrowUp': emit({ ...p, y: p.y + stepY }); break; case 'ArrowRight': emit({ ...p, x: p.x + (ax.invert ? -stepX : stepX) }); break;
case 'ArrowDown': emit({ ...p, y: p.y - stepY }); break; case 'ArrowUp': emit({ ...p, y: p.y + (ay.invert ? -stepY : stepY) }); break;
case 'ArrowDown': emit({ ...p, y: p.y - (ay.invert ? -stepY : stepY) }); break;
default: handled = false; default: handled = false;
} }
if (handled) e.preventDefault(); if (handled) e.preventDefault();
@ -262,7 +299,6 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
userSelect: 'none', userSelect: 'none',
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair', cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair',
opacity: disabled ? 0.45 : undefined,
outline: 'none', outline: 'none',
borderRadius: 10, borderRadius: 10,
...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined), ...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),