Compare commits

..

No commits in common. "master" and "v1.1.0" have entirely different histories.

41 changed files with 231 additions and 2690 deletions

View file

@ -35,11 +35,9 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
power / custom tapers.
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
`SteppedKnob`, `PanKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`
(PPM ballistics · dBFS · loudness), `MeterBridge`, `Gauge`, `XYPad`, `PushButton`,
`Button`, `IndicatorLamp`, `LampRow`, `ToggleSwitch`, `SegmentSwitch`,
`TransportButton`, `ScrubField`, `SegmentDisplay`, `AlphaDisplay`, plus
`LabeledField`/`Rack` layout — every color themeable per instance.
`SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `MeterBridge`,
`XYPad`, `PushButton`, `IndicatorLamp`, `SegmentSwitch`, `ScrubField`,
`SegmentDisplay`, `AlphaDisplay` — every color themeable per instance.
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
`useKnob`.
@ -51,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:
```bash
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.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):

View file

@ -1,7 +1,6 @@
import React from 'react';
import { AdvancedGuide } from './sections/AdvancedGuide';
import { ApiDocs } from './sections/ApiDocs';
import { Buttons } from './sections/Buttons';
import { Console } from './sections/Console';
import { Gallery } from './sections/Gallery';
import { GettingStarted } from './sections/GettingStarted';
@ -27,7 +26,6 @@ export const App: React.FC = () => (
<div className="nav-links">
<a href="#start">Start</a>
<a href="#gallery">Gallery</a>
<a href="#buttons">Buttons</a>
<a href="#synth">Synth</a>
<a href="#playground">Playground</a>
<a href="#guide">Guide</a>
@ -38,7 +36,6 @@ export const App: React.FC = () => (
<Hero />
<GettingStarted />
<Gallery />
<Buttons />
<Console />
<Synth />
<Playground />

View file

@ -128,34 +128,24 @@ export const ApiDocs: React.FC = () => (
<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="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
<Row name="<PanKnob />" type="2D · bipolar" desc="Stereo pan: fills from center, snaps to a center detent, reads out L<n>/C/R<n>. Defaults to 50…50 centered at 0; pass min/max for other conventions." />
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format, valuePosition ('top' | 'end' | 'none'), fill (stretch to container)." />
<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, fill (stretch to container)." />
<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}] — 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="<Meter />" type="display" desc="LED level meter: value (number or [l, r, ...]), zones, peakHold, peakDecay (fall tail), ballistics (PPM attack/decay), scale='db' (feed amplitude → dBFS + reference lines), integrated (windowed loudness), fill, disabled, seven-seg readout, clip LED (showClip/clipThreshold/clipHold 'latch'/clipColor/onClip). All the new metering is opt-in." />
<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="<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="<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="<Button />" type="action" desc="Click-to-fire command button in the panel style (Regenerate, Add, Apply). PushButton locked to mode='action' — never latches, press-down, onClick. No LED by default (an action has no state); opt in with led='dot' | 'strip'. Forwards native button props → usable as a Radix asChild trigger." />
<Row name="<PushButton />" type="switch" desc="Panel button: mode 'toggle' | 'momentary' | 'action', controlled/uncontrolled, aria-pressed. led defaults to 'strip' for toggle/momentary (state) and off for action; also 'dot'. leadingIcon slot. Forwards ...rest and composes handlers, so it works as a dropdown/tooltip/popover trigger." />
<Row name="<IndicatorLamp />" type="switch · display" desc="Panel LED lamp: pressable toggle (role='switch') or read-only status light, blink, momentary, label either side (labelSize to shrink the caption below its 10px floor) — the checkbox, in hardware form." />
<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 (now incl. : $ %), chars padding, align, weight, gap, glow, skew. Defaults to theme.ledAmber." />
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, weight (thicker strokes), gap (kerning), valueColor / zones (color by value), bold own-cell decimal + colon, disabled. Defaults to theme.ledGreen." />
<Row name="<Gauge />" type="display" desc="Read-only radial arc meter — the round sibling of Meter. value/min/max/taper, zones or valueColor, needle, center readout, unit. For CPU/RAM/load." />
<Row name="<ToggleSwitch />" type="switch" desc="Rocker/slider boolean (role='switch'): the knob slides OFF↔ON. label, showStateLabel, ←/→ keys, controlled/uncontrolled, name." />
<Row name="<TransportButton />" type="switch" desc="play / pause / stop / record / rewind / forward / panic, styled to match the controls beside it. active lights it; record blinks (reduced-motion aware)." />
<Row name="<LampRow />" type="display" desc="Row of small indicator lamps with console-print captions (● CUE ● MUTE). Read-only status, or pass onToggle to make them pressable." />
<Row name="<LabeledField />" type="layout" desc="One aligned inspector row: label · control · unit. Share labelWidth across a stack and the controls line up." />
<Row name="<Rack />" type="layout" desc="Channel-strip / rack container owning panel chrome + spacing: orientation row|column, title, nestable. Declarative console sections." />
<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." />
</Table>
<p className="api-note">
Every knob skin and fader also takes <code>label</code>, <code>sublabel</code>{' '}
(a second caption line <code>SEED</code> over <code>world #7</code>),{' '}
<code>labelSize</code> (caption size independent of the dial diameter),{' '}
<code>showValue</code>, <code>unit</code>, <code>format</code>,{' '}
<code>className</code>, <code>style</code>, <code>name</code> plus every core prop
above, and forwards a ref to its root element (<code>VintageKnob</code> has no readout, so it omits{' '}
Every knob skin and fader also takes <code>label</code>, <code>showValue</code>,{' '}
<code>unit</code>, <code>format</code>, <code>className</code>,{' '}
<code>style</code>, <code>name</code> plus every core prop above, and forwards a
ref to its root element (<code>VintageKnob</code> has no readout, so it omits{' '}
<code>showValue</code>/<code>unit</code>/<code>format</code>; the display
components <code>Meter</code>/<code>SegmentDisplay</code>/<code>AlphaDisplay</code>{' '}
have their own smaller prop sets). Primitive-drawn parts carry{' '}
@ -190,12 +180,8 @@ export const ApiDocs: React.FC = () => (
label: 'rgba(255,255,255,0.5)',
fontMono: '"IBM Plex Mono", monospace',
zoneGood: '#3df2ad', // meter zones: green / amber / red
zoneWarn: '#ffd23e', // used by Meter, MeterBridge, Gauge
zoneHot: '#ff4d6b', // and LEDFader when no zones given
ledGreen: '#3df2ad', // SegmentDisplay default lit color
ledAmber: '#ffb84d', // AlphaDisplay default lit color
panel: 'rgba(255,255,255,0.03)', // Rack / panel chrome
ledWell: '#0b0d0e', // LED display well (LEDKnob/LEDFader/Meter)
zoneWarn: '#ffd23e', // used by Meter, MeterBridge and
zoneHot: '#ff4d6b', // LEDFader when no zones are given
}}
>
<FlatKnob ... /> <Fader ... /> <LEDKnob ... />
@ -248,18 +234,6 @@ function MyKnob() {
}`}
/>
<h3 className="api-h">Small readouts</h3>
<p className="api-note">
Segment displays keep their proportions down to about{' '}
<strong>11&nbsp;px</strong> tall; below that, raise <code>weight</code> to thicken
the strokes rather than shrinking further. The decimal point and colon render as
bold cells with their own width (not hairlines between digits), so{' '}
<code>17.5</code> and <code>1:23:45</code> stay readable in a status bar. Widen{' '}
<code>gap</code> when packed tokens like <code>18.1/24</code> crowd. Colons and{' '}
<code>$</code>/<code>%</code> are in the charset, so a whole token can live in one
display instead of text beside it.
</p>
<h3 className="api-h">Recipes</h3>
<p className="api-note">
The prop combinations we reach for in real consoles and inspectors.
@ -291,47 +265,7 @@ function MyKnob() {
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} />
// Resource gauge — colors itself green → amber → red as load climbs
<Gauge value={cpu} min={0} max={100} unit="%" label="CPU" size={96}
zones={[{upTo: 0.75, color: '#3df2ad'},
{upTo: 0.9, color: '#ffd23e'},
{upTo: 1, color: '#ff4d6b'}]} />
// Self-coloring resource readout — no app-side color math
<SegmentDisplay value={cpu} digits={3} unit="%" weight={1.4}
zones={[{upTo: 74, color: '#3df2ad'},
{upTo: 89, color: '#ffd23e'},
{upTo: Infinity, color: '#ff4d6b'}]} />
// Aligned inspector rows — share labelWidth so controls line up
<LabeledField label="Radius" unit="px" labelWidth={64}>
<ScrubField defaultValue={12} min={0} max={200} />
</LabeledField>
<LabeledField label="Fresh seed" labelWidth={64}>
<ToggleSwitch defaultOn showStateLabel />
</LabeledField>
// A declarative channel strip — now with a pan knob
<Rack orientation="column" title="Channel 1">
<FlatKnob label="Gain" sublabel="dB" />
<PanKnob size={56} defaultValue={0} />
<Fader orientation="horizontal" fill valuePosition="end" />
<Meter value={level} min={-60} max={6} fill orientation="horizontal" />
</Rack>
// A pro dBFS meter — feed amplitude, get ballistics + peak-decay + loudness
<Meter value={[ampL, ampR]} scale="db" ballistics
peakHold={1500} peakDecay={0.25} integrated showValue unit="dB" />
// A Button as a Radix dropdown trigger (it forwards + composes handlers)
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button led={false}>Add channel</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>{/* … */}</DropdownMenuContent>
</DropdownMenu>`}
<Meter value={[left, right]} min={-60} max={6} clipThreshold={0} />`}
/>
<h3 className="api-h">Precision &amp; number handling</h3>

View file

@ -1,157 +0,0 @@
import React from 'react';
import {
Button,
PushButton,
TransportButton,
ToggleSwitch,
SegmentSwitch,
} from 'dreamknob';
/** Small glyph for a button's leadingIcon (the docs app has no icon dep). */
const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span aria-hidden="true" style={{ fontSize: 14, lineHeight: 1 }}>
{children}
</span>
);
/** The button/switch family, side by side, with a "which do I use?" guide. */
export const Buttons: React.FC = () => {
const [playing, setPlaying] = React.useState(false);
const [armed, setArmed] = React.useState(false);
const [bypass, setBypass] = React.useState(false);
const [fresh, setFresh] = React.useState(true);
const [mode, setMode] = React.useState(0);
const [fired, setFired] = React.useState(0);
return (
<section className="block" id="buttons">
<div className="container">
<div className="section-kicker">Buttons &amp; switches</div>
<h2 className="section-title">Press, latch, or pick</h2>
<p className="section-sub">
Five components cover every panel control that isn&apos;t a dial. The
only question is what the press <em>means</em>:
</p>
<div className="guide-grid">
<GuideRow
code="<Button>"
when="Fire a one-off action"
eg="Regenerate · Add · Apply · Refresh"
/>
<GuideRow
code="<PushButton>"
when="Latch a boolean state (or hold it)"
eg="Mute · Solo · Bypass · push-to-talk"
/>
<GuideRow
code="<TransportButton>"
when="Transport / deck control"
eg="Play · Stop · Record · Panic"
/>
<GuideRow
code="<ToggleSwitch>"
when="An on/off setting that reads as a switch"
eg="Fresh seed · Monitor · Loop"
/>
<GuideRow
code="<SegmentSwitch>"
when="Pick one of a few modes"
eg="Linear · Radial · Conic"
/>
</div>
<div className="btn-demo">
<Demo label={`<Button> — action, fired ${fired}×`}>
<Button
color="#4cc2ff"
leadingIcon={<Glyph></Glyph>}
onClick={() => setFired(n => n + 1)}
aria-label="Regenerate"
>
Regenerate
</Button>
<Button
color="#3df2ad"
led="dot"
leadingIcon={<Glyph></Glyph>}
onClick={() => setFired(n => n + 1)}
aria-label="Fresh"
>
Fresh
</Button>
<Button
color="#ff4d6b"
led="strip"
leadingIcon={<Glyph></Glyph>}
onClick={() => setFired(n => n + 1)}
aria-label="Clear"
>
Clear
</Button>
</Demo>
<Demo label="<PushButton> — latch / hold">
<PushButton
pressed={bypass}
onChange={setBypass}
color="#ffd23e"
aria-label="Bypass"
>
Bypass
</PushButton>
<PushButton
mode="momentary"
color="#4cc2ff"
led="dot"
size={30}
aria-label="Talk"
>
Talk
</PushButton>
</Demo>
<Demo label="<TransportButton>">
<TransportButton kind="play" active={playing} onClick={() => setPlaying(p => !p)} size={34} />
<TransportButton kind="stop" onClick={() => setPlaying(false)} size={34} />
<TransportButton kind="record" active={armed} onClick={() => setArmed(a => !a)} size={34} />
</Demo>
<Demo label="<ToggleSwitch> · <SegmentSwitch>">
<ToggleSwitch on={fresh} onChange={setFresh} color="#c9a6ff" showStateLabel aria-label="Fresh seed" />
<SegmentSwitch
options={['LINEAR', 'RADIAL', 'CONIC']}
value={mode}
onChange={setMode}
color="#4cc2ff"
aria-label="Gradient kind"
/>
</Demo>
</div>
<p className="section-sub" style={{ marginTop: 20 }}>
<code>&lt;Button&gt;</code> is <code>&lt;PushButton mode=&quot;action&quot;&gt;</code>
it never latches, shows a press-down, and calls <code>onClick</code>. It has{' '}
<strong>no LED by default</strong> an action has no state to indicate, unlike a
toggle. Add <code>led=&quot;dot&quot;</code> for a corner dot that flashes on
press, or <code>led=&quot;strip&quot;</code> for the full strip.
</p>
</div>
</section>
);
};
const GuideRow: React.FC<{ code: string; when: string; eg: string }> = ({ code, when, eg }) => (
<div className="guide-row">
<code className="guide-code">{code}</code>
<span className="guide-when">{when}</span>
<span className="guide-eg">{eg}</span>
</div>
);
const Demo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div className="btn-demo-cell">
<div className="btn-demo-label">{label}</div>
<div className="btn-demo-row">{children}</div>
</div>
);

View file

@ -2,35 +2,27 @@ import React from 'react';
import {
AlphaDisplay,
Arc,
Button,
DreamknobProvider,
Fader,
FlatKnob,
Gauge,
ImageKnob,
Knob,
KnobValue,
IndicatorLamp,
LabeledField,
LampRow,
LEDFader,
LEDKnob,
MetalKnob,
Meter,
MeterBridge,
NeonKnob,
PanKnob,
Pointer,
PushButton,
Rack,
RubberKnob,
ScrubField,
SegmentSwitch,
SegmentDisplay,
SteppedKnob,
Ticks,
ToggleSwitch,
TransportButton,
VintageKnob,
XYPad,
logTaper,
@ -205,189 +197,6 @@ const CommitModeDemo: React.FC = () => {
);
};
const RESOURCE_ZONES = [
{ upTo: 0.75, color: '#3df2ad' },
{ upTo: 0.9, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
];
const GaugeDemo: React.FC = () => {
const [load, setLoad] = React.useState<[number, number]>([38, 61]);
React.useEffect(() => {
let t = 0;
const id = setInterval(() => {
t += 0.12;
setLoad([
Math.max(2, Math.min(100, 45 + Math.sin(t) * 30 + Math.random() * 12)),
Math.max(2, Math.min(100, 62 + Math.sin(t * 0.7 + 2) * 26 + Math.random() * 10)),
]);
}, 140);
return () => clearInterval(id);
}, []);
return (
<div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
<Gauge value={load[0]} min={0} max={100} size={104} unit="%" label="CPU" zones={RESOURCE_ZONES} />
<Gauge value={load[1]} min={0} max={100} size={104} unit="%" label="GPU" zones={RESOURCE_ZONES} needle={false} />
</div>
);
};
const SmallReadoutDemo: React.FC = () => {
const [t, setT] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setT(v => v + 1), 1000);
return () => clearInterval(id);
}, []);
const hh = String(Math.floor(t / 3600) % 100).padStart(2, '0');
const mm = String(Math.floor(t / 60) % 60).padStart(2, '0');
const ss = String(t % 60).padStart(2, '0');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<SegmentDisplay value={17.5} digits={3} decimals={1} height={13} weight={1.5} />
<SegmentDisplay value={`${hh}:${mm}:${ss}`} height={13} weight={1.4} />
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<SegmentDisplay value={82} digits={3} height={16} weight={1.4} zones={[
{ upTo: 74, color: '#3df2ad' },
{ upTo: 89, color: '#ffd23e' },
{ upTo: Infinity, color: '#ff4d6b' },
]} />
<AlphaDisplay value="$4.20" height={15} weight={1.3} />
<AlphaDisplay value="47%" height={15} weight={1.3} color="#3df2ad" />
</div>
</div>
);
};
const PanelDemo: React.FC = () => {
const [fresh, setFresh] = React.useState(true);
const [mode, setMode] = React.useState(0);
const [playing, setPlaying] = React.useState(false);
const [armed, setArmed] = React.useState(false);
return (
<Rack orientation="column" title="Performer" accentColor="#c9a6ff">
<div style={{ display: 'flex', gap: 16, alignItems: 'center', justifyContent: 'center' }}>
<FlatKnob size={62} defaultValue={7} min={0} max={64} step={1} color="#c9a6ff"
label="Seed" sublabel="world #7" labelSize={9} aria-label="Seed" />
<FlatKnob size={62} defaultValue={40} min={0} max={100} color="#c9a6ff"
label="Refresh" sublabel="every 40" labelSize={9} aria-label="Refresh" />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<TransportButton kind="play" active={playing} onClick={() => setPlaying(p => !p)} size={34} />
<TransportButton kind="stop" onClick={() => setPlaying(false)} size={34} />
<TransportButton kind="record" active={armed} onClick={() => setArmed(a => !a)} size={34} />
<TransportButton kind="panic" onClick={() => { setPlaying(false); setArmed(false); }} size={34} />
</div>
<LabeledField label="Fresh seed" labelWidth={78}>
<ToggleSwitch on={fresh} onChange={setFresh} color="#c9a6ff" showStateLabel aria-label="Fresh seed" />
</LabeledField>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<SegmentSwitch options={['SOLID', 'GRAD', 'NOISE']} value={mode} onChange={setMode} color="#c9a6ff" aria-label="Fill mode" />
<Button color="#c9a6ff" size={30} title="Regenerate the world" aria-label="Regenerate"></Button>
</div>
<LampRow
lamps={[
{ label: 'Cue', on: playing, color: '#3df2ad' },
{ label: 'Rec', on: armed, color: '#ff4d6b' },
{ label: 'Seed', on: fresh, color: '#c9a6ff' },
]}
/>
</Rack>
);
};
const DbMeterDemo: React.FC = () => {
// Drive with linear amplitude; the meter converts to dBFS internally.
const [amp, setAmp] = React.useState<[number, number]>([0.3, 0.28]);
React.useEffect(() => {
let t = 0;
const id = setInterval(() => {
t += 0.11;
const spike = Math.random() < 0.05 ? 0.4 : 0;
setAmp([
Math.max(0.001, Math.min(1.2, 0.34 + Math.sin(t) * 0.22 + Math.random() * 0.16 + spike)),
Math.max(0.001, Math.min(1.2, 0.32 + Math.sin(t * 1.25 + 1) * 0.22 + Math.random() * 0.16 + spike)),
]);
}, 90);
return () => clearInterval(id);
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'center' }}>
<Meter
value={amp}
scale="db"
ballistics
peakHold={1400}
peakDecay={0.22}
integrated
clipThreshold={0}
length={140}
showValue
unit="dB"
label="Vertical"
aria-label="dBFS meter"
/>
<Meter
value={amp}
scale="db"
ballistics
peakHold={1400}
peakDecay={0.22}
clipThreshold={0}
orientation="horizontal"
length={190}
breadth={14}
label="Horizontal"
aria-label="dBFS meter horizontal"
/>
</div>
);
};
const PanDemo: React.FC = () => (
<div style={{ display: 'flex', gap: 22, alignItems: 'center' }}>
<PanKnob size={84} defaultValue={0} color="#4cc2ff" aria-label="Pan center" />
<PanKnob size={84} defaultValue={-30} color="#3df2ad" label="Track 2" aria-label="Pan left" />
</div>
);
const LightThemeDemo: React.FC = () => {
const [play, setPlay] = React.useState(false);
const [mute, setMute] = React.useState(true);
return (
<DreamknobProvider base="light" theme={{ accent: '#0868c8' }}>
<div
style={{
background: '#eceef2',
borderRadius: 12,
padding: 18,
display: 'flex',
flexDirection: 'column',
gap: 16,
alignItems: 'center',
}}
>
<div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
<LEDKnob size={74} min={20} max={2000} defaultValue={440} taper={logTaper} label="Freq" unit="Hz" aria-label="Light freq" />
<SegmentDisplay value={128.4} digits={4} decimals={1} height={22} />
<Meter value={[62, 44]} min={0} max={100} length={92} breadth={14} aria-label="Light meter" />
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<PushButton pressed={mute} onChange={setMute} color="#d92546" size={32} aria-label="Mute">
Mute
</PushButton>
<Button color="#0868c8" size={32} onClick={() => undefined} aria-label="Apply">
Apply
</Button>
<TransportButton kind="play" active={play} onClick={() => setPlay(p => !p)} size={32} />
<SegmentSwitch options={['LP', 'BP', 'HP']} defaultValue={1} aria-label="Light filter" />
</div>
</div>
</DreamknobProvider>
);
};
const ImageKnobDemo: React.FC = () => {
const src = useGeneratedStrip();
if (!src) return null;
@ -601,18 +410,10 @@ export const Gallery: React.FC = () => (
</div>
</Card>
<Card title="Light theme" desc="base='light' adapts the whole panel — LED wells go transparent so readouts and meters read as flat segments, and the buttons/switches become light raised chrome instead of dark chips." tag="<DreamknobProvider base='light' />">
<LightThemeDemo />
</Card>
<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 />
</Card>
<Card title="dBFS meter" desc="The same <Meter>, in dB mode: feed linear amplitude and it shows dBFS with PPM ballistics, a peak-decay tail, reference lines and a windowed loudness readout." tag="<Meter scale='db' ballistics integrated />">
<DbMeterDemo />
</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>
@ -628,22 +429,6 @@ export const Gallery: React.FC = () => (
<CommitModeDemo />
</Card>
<Card title="Gauge" desc="Read-only radial arc meter — the round sibling of Meter. Colors itself green→amber→red as load climbs." tag="<Gauge />">
<GaugeDemo />
</Card>
<Card title="Small readouts" desc="Bold own-cell decimal + colon, weight to thicken strokes, and value-colored zones — legible at status-bar sizes. Colons and $/% are in the charset." tag="weight · zones · : $ %">
<SmallReadoutDemo />
</Card>
<Card title="Panel: strip + switches" desc="Rack chrome, ToggleSwitch, TransportButton, SegmentSwitch and a LampRow — a console section, declarative." tag="<Rack /> · <ToggleSwitch /> · <TransportButton />">
<PanelDemo />
</Card>
<Card title="Pan" desc="Bipolar stereo pan: fills from center, snaps to a center detent, and reads out L/C/R." tag="<PanKnob />">
<PanDemo />
</Card>
<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">
<Arc

View file

@ -30,12 +30,12 @@ export const GettingStarted: React.FC = () => (
<Step n={1} title="Install">
<CodeBlock
code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.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 }}>
Installs straight from the{' '}
<a href="https://git.dreamodus.software/Dreamodus/DreamKnob/releases">
v1.1.4 release
v1.1.0 release
</a>{' '}
on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No
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.
</p>
<div className="install">
<span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz</span>
<span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz</span>
<button
onClick={() => {
navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz');
navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz');
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}

View file

@ -353,76 +353,6 @@ h2.section-title {
text-transform: uppercase;
}
/* Buttons section: decision guide + live demo grid */
.guide-grid {
display: flex;
flex-direction: column;
gap: 1px;
margin: 8px 0 28px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--line);
background: var(--line);
}
.guide-row {
display: grid;
grid-template-columns: 190px 1fr;
gap: 4px 18px;
align-items: baseline;
padding: 12px 16px;
background: #101219;
}
.guide-code {
font-family: var(--font-mono);
font-size: 13px;
color: #4cc2ff;
}
.guide-when {
font-size: 14px;
color: var(--text);
}
.guide-eg {
grid-column: 2;
font-size: 12.5px;
color: var(--text-dim);
}
.btn-demo {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.btn-demo-cell {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: 12px;
background: linear-gradient(180deg, #16181f, #0e0f14);
border: 1px solid var(--line-strong);
}
.btn-demo-label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--text-dim);
text-transform: uppercase;
}
.btn-demo-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-height: 40px;
}
@media (max-width: 560px) {
.guide-row {
grid-template-columns: 1fr;
}
.guide-eg {
grid-column: 1;
}
}
/* ---- synth panel ---- */
.synth-rows {
display: flex;

View file

@ -1,141 +1,5 @@
# Changelog
## 1.1.4 — 2026-07-14
Light-theme support — the controls whose chrome was hardcoded dark now adapt.
### Themeable LED well
- **`ledWell` theme token** — the recessed "screen" fill behind the LED
segments of `LEDKnob`, `LEDFader`, `Meter`, and the default well of
`SegmentDisplay` / `AlphaDisplay`, now comes from the theme instead of a
hardcoded near-black. Dark base keeps the classic dark well (`#0b0d0e`); the
**light base is `transparent`**, so LED controls and readouts render as clean
flat segment rings / bar meters / digits on the light panel (still clearly
"LED" from the lit segments) instead of dark blobs. The `faceColor` /
`background` props still override per-instance.
### Light-theme panel chrome
- **`scheme` theme token** (`'dark' | 'light'`, set by the `base`) drives the
raised gradient/border/shadow of `PushButton` (and `Button`),
`TransportButton` and `SegmentSwitch`. On a light base they render as light
raised buttons instead of dark chips; unlit LED pips flip to a faint dark so
they still read. New `panelButtonChrome(scheme, pressed)` helper.
- Fixes the whole panel-button/switch family looking like dark chips on light
themes; the flat knobs and faders already adapted (their face is the `face`
token).
## 1.1.3 — 2026-07-14
Fourth integration-feedback release — a composability fix for buttons, a much
smarter meter, and the pan control the mixer was missing.
### Buttons
- **Action buttons no longer show an LED by default.** `Button` (and
`PushButton mode="action"`) are plain by default — an action has no state to
indicate. `led` defaults to `'strip'` for `toggle`/`momentary` (which do have
a state) and to `false` for `action`; pass `led="dot"` / `led="strip"` to opt
a command button back in.
### Buttons compose as Radix triggers
- `PushButton` / `Button` now forward unknown props (`...rest`) to the
underlying `<button>` and **compose** their own event handlers with any the
consumer (or a Radix `asChild` slot) injects. A `Button` can now be a
dropdown / tooltip / popover trigger: `onPointerDown`, `onKeyDown`,
`data-state`, `aria-expanded` etc. all reach the DOM and fire alongside the
button's own press behavior. The prop types now extend the native button
attributes.
### Meter — ballistics, dBFS, loudness
- **`ballistics`** — PPM-style attack/decay smoothing of the displayed level
(`true` ≈ 5 ms attack / 350 ms decay, or `{ attack, decay }` in ms).
- **`peakDecay`** — the peak-hold marker falls with a decay tail (normalized
units/second) after `peakHold` instead of snapping back.
- **`scale="db"`** — feed linear amplitude (0..1) and the meter displays dBFS;
`min`/`max` default to -60 / 0 dB and faint reference gridlines are drawn
(`showScale` / `referenceTicks` to tune). New `ampToDb` / `dbToAmp` helpers.
- **`integrated`** — a time-windowed loudness estimate (LUFS-ish on the dB
scale — a windowed mean of channel power, not a full BS.1770 measurement),
shown as a secondary readout and a marker line (`integrationWindow`,
`loudnessColor`).
- All of the above are opt-in; the default meter behaves exactly as before.
### New component
- **`PanKnob`** — a bipolar stereo-pan knob: fills from center, snaps to a
center detent, reads out `L<n>` / `C` / `R<n>`. Defaults to a 50…50 range
centered at 0.
## 1.1.2 — 2026-07-14
Third integration-feedback release — a general action button, responsive
faders everywhere, and the label-scaling gap, all surfaced building a
resizable streaming studio.
### New components
- `Button` — a normal **click-to-fire** command button in the panel style
(Regenerate, Add, Apply, Refresh), the non-latching sibling of `PushButton`.
It's `PushButton` locked to the new `mode="action"`: never latches, shows a
press-down + a brief LED flash, calls `onClick`. `led="dot"` (default) gives
the corner-dot look; `led={false}` a plain button.
### Buttons
- `PushButton` gains **`mode="action"`** (alongside `toggle`/`momentary`) and
an `onClick` prop — the stateless command-button behavior `Button` wraps.
### Responsive faders
- **`fill`** on `LEDFader` (matching `Fader`) — the fader stretches along its
travel axis to fill the container instead of a fixed `length`, so faders in
resizable dock panels size themselves with no wrapper measurement.
### Label scaling
- **`labelSize`** on `IndicatorLamp` — the caption otherwise tracks `size` but
is floored at 10px; set this to shrink it with a small lamp (e.g. matching a
dense header row). Also on `LampRow` for parity.
### Docs
- A dedicated **Buttons & switches** section with a "which do I use?" guide
(Button = action · PushButton = latch · TransportButton = transport ·
ToggleSwitch = boolean · SegmentSwitch = mode) and a live demo of each.
## 1.1.1 — 2026-07-13
Second integration-feedback release — the friction that showed up once the
displays and switches went into dense, resizable, themed product panels.
### Readout legibility
- Seven-segment decimal point and colon now render as **bold cells with their
own width** (not hairlines between digits), so `17.5` and `1:23:45` stay
readable at status-bar sizes
- `weight` prop on `SegmentDisplay`/`AlphaDisplay` thickens the segments (a font
weight); `gap` controls inter-cell spacing for crowded readouts like `18.1/24`
### Displays: theme + value colors
- `SegmentDisplay` defaults to `theme.ledGreen`, `AlphaDisplay` to
`theme.ledAmber` — theme your readouts once instead of prop-drilling `color`
- `valueColor: (v) => string` and value-space `zones` on `SegmentDisplay` — a
resource readout colors itself green/amber/red without app-side math
- Charset: `:` colon (both displays), `$` and `%` (14-seg)
- `disabled` + `data-disabled` on displays (uniform dimming like the controls)
### Knob captions
- `sublabel` — a second caption line (`SEED` over `world #7`)
- `labelSize` — caption size independent of the dial diameter
### New components
- `ToggleSwitch` — rocker/slider boolean (`role="switch"`), the knob slides
OFF↔ON; `showStateLabel`, ←/→ keys
- `Gauge` — read-only radial arc meter (the round sibling of `Meter`) for
CPU/RAM/load; zones or `valueColor`, needle, center readout
- `TransportButton` — play/pause/stop/record/rewind/forward/panic family styled
to match the controls beside it; `record` blinks (reduced-motion aware)
- `LampRow` — a row of indicator lamps with console-print captions
- `LabeledField` — label · control · unit in one aligned row (share `labelWidth`)
- `Rack` — channel-strip / rack container owning panel chrome + spacing
### Responsiveness & niceties
- `fill` on `Meter` and `Fader` — stretch to the container instead of fixed px
- `led="dot"` corner behavior plus a `leadingIcon` slot on `PushButton`
- Theme gains `ledGreen`, `ledAmber`, `panel` tokens
## 1.1.0 — 2026-07-13
Integration-feedback release — everything in here came from wiring dreamknob into a

View file

@ -5,7 +5,7 @@ headless core, any number range, any decimal precision, and colors you fully con
Zero runtime dependencies.
```
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.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)
@ -73,7 +73,7 @@ when you provide `getAriaValueText`).
the cursor mid-gesture.
- `wheelStep` / `wheelRequiresFocus` — tune wheel notches, or keep hover-scroll free.
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'edit' | 'api'`).
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'`).
- `wrap` — endless encoder mode: values roll around min↔max (pair with
`angleOffset={0} angleRange={360}`).
- `animateChanges` — tween the pointer on programmatic changes (preset loads);
@ -95,36 +95,21 @@ import {
LEDKnob, // segmented LED ring + true seven-segment readout
NeonKnob, // glowing arc for dark UIs
SteppedKnob, // detented selector (positions={['LP','BP','HP']})
PanKnob, // bipolar stereo pan — center detent, L<n>/C/R<n> readout
Fader, // linear channel fader, vertical or horizontal
LEDFader, // segmented LED meter-fader with color zones
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
Meter, // LED level meter — multi-channel, PPM ballistics, dBFS scale, loudness
Meter, // read-only LED level meter — mono or multi-channel (value={[l, r]})
MeterBridge, // labeled multi-strip meter block with per-channel peak text
Gauge, // read-only radial arc meter (round sibling of Meter) for CPU/RAM/load
XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
PushButton, // latch/hold panel button — LED strip or corner dot (toggle/momentary)
Button, // click-to-fire command button (no LED by default; led="dot" opt-in)
PushButton, // panel button with LED strip or corner dot (toggle or momentary)
IndicatorLamp, // pressable LED lamp — the hardware checkbox (blink, momentary)
LampRow, // row of indicator lamps with console-print captions
ToggleSwitch, // rocker/slider boolean — the knob slides OFF↔ON
SegmentSwitch, // segmented option selector (radiogroup, LED tick)
TransportButton,// play/stop/record/pause/panic family (record blinks)
ScrubField, // AE-style numeric field: drag the label to scrub, click to type
SegmentDisplay, // seven-segment numeric display (weight, gap, value-color zones)
AlphaDisplay, // fourteen-segment alphanumeric display (now incl. : $ %)
LabeledField, // label · control · unit in one aligned inspector row
Rack, // channel-strip / rack container (panel chrome + spacing)
SegmentDisplay, // standalone seven-segment numeric display
AlphaDisplay, // fourteen-segment alphanumeric display
} from 'dreamknob'
```
Knob skins also take `sublabel` (a second caption line) and `labelSize` (caption
size independent of the dial); `Meter` and `Fader` take `fill` to stretch to their
container. `Meter` adds `ballistics` (PPM attack/decay), `peakDecay`, `scale="db"`
(feed amplitude, show dBFS) and `integrated` (windowed loudness) — all opt-in.
`PushButton`/`Button` forward native button props and compose handlers, so a
`Button` works as a Radix `asChild` trigger.
## Theming
```tsx
@ -136,10 +121,7 @@ import { DreamknobProvider } from 'dreamknob'
```
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
`text`, `label`, `ticks`, `focusRing`, `zoneGood`/`zoneWarn`/`zoneHot` (meter zones),
`ledGreen`/`ledAmber` (display defaults), `panel` (rack chrome), `ledWell` (LED display
well — transparent on the light base), `fontMono`, `fontUI`. `scheme` (`'dark'|'light'`)
is set by `base` and drives the panel-button/switch chrome; you rarely set it directly.
`text`, `label`, `ticks`, `focusRing`, `fontMono`, `fontUI`.
Every knob skin and fader takes the core props plus `size`, `label`, `showValue`,
`unit`, `format`, and per-part color props (`color`, `trackColor`, `faceColor`,

View file

@ -1,6 +1,6 @@
{
"name": "dreamknob",
"version": "1.1.4",
"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.",
"license": "MIT",
"author": "Dreamodus Software Inc.",

View file

@ -34,64 +34,10 @@ export interface KnobTheme {
zoneWarn: string;
/** Meter/LED zone semantics: too hot / clipping. */
zoneHot: string;
/** Default lit color for green seven-segment readouts (`SegmentDisplay`). */
ledGreen: string;
/** Default lit color for amber alphanumeric readouts (`AlphaDisplay`). */
ledAmber: string;
/** Panel/rack chrome background (`Rack`, panel containers). */
panel: string;
/**
* The "well" behind LED displays the recessed screen of `LEDKnob`,
* `LEDFader`, `Meter` and the default panel of `SegmentDisplay` /
* `AlphaDisplay`. Dark base keeps the classic near-black well (`#0b0d0e`) so
* lit segments read as glowing; the light base is `transparent`, so LED
* controls and readouts render as clean flat segment rings / bar meters /
* digits on the light panel instead of dark blobs. Per-instance
* `faceColor` / `background` props still override it.
*/
ledWell: string;
/**
* Which base this theme derives from. Panel chrome that can't be expressed
* as a single token the raised gradient/border/shadow of `PushButton`,
* `TransportButton`, `SegmentSwitch` switches between dark and light
* treatments on this. Set automatically by the `base`; overrides rarely
* need to touch it.
*/
scheme: 'dark' | 'light';
fontMono: string;
fontUI: string;
}
/** Raised panel-button chrome (background gradient, border, inset/drop shadow)
* for the current scheme shared by PushButton, TransportButton and the
* SegmentSwitch container so they stay consistent and adapt to light themes
* instead of being hardcoded dark chips. */
export function panelButtonChrome(
scheme: 'dark' | 'light',
pressed: boolean,
): { background: string; border: string; boxShadow: string } {
if (scheme === 'light') {
return {
border: '1px solid rgba(0,0,0,0.16)',
background: pressed
? 'linear-gradient(180deg, #d9dbe0, #e9eaee)'
: 'linear-gradient(180deg, #ffffff, #e7e8ec)',
boxShadow: pressed
? 'inset 0 2px 4px rgba(0,0,0,0.16)'
: 'inset 0 1px 0 rgba(255,255,255,0.9), 0 1px 2px rgba(0,0,0,0.13)',
};
}
return {
border: '1px solid rgba(0,0,0,0.7)',
background: pressed
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
boxShadow: pressed
? 'inset 0 2px 5px rgba(0,0,0,0.65)'
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)',
};
}
export const darkTheme: KnobTheme = {
track: 'rgba(255,255,255,0.12)',
face: 'rgba(255,255,255,0.05)',
@ -102,11 +48,6 @@ export const darkTheme: KnobTheme = {
zoneGood: '#3df2ad',
zoneWarn: '#ffd23e',
zoneHot: '#ff4d6b',
ledGreen: '#3df2ad',
ledAmber: '#ffb84d',
panel: 'rgba(255,255,255,0.03)',
ledWell: '#0b0d0e',
scheme: 'dark',
fontMono: MONO_FONT,
fontUI: UI_FONT,
};
@ -121,11 +62,6 @@ export const lightTheme: KnobTheme = {
zoneGood: '#0fa571',
zoneWarn: '#c99000',
zoneHot: '#d92546',
ledGreen: '#0fa571',
ledAmber: '#b26a00',
panel: 'rgba(0,0,0,0.04)',
ledWell: 'transparent',
scheme: 'light',
fontMono: MONO_FONT,
fontUI: UI_FONT,
};

View file

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

View file

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

View file

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

View file

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

View file

@ -21,7 +21,6 @@ export type {
ChangeSource,
} from './core/types';
export { mixColors, sampleGradient, type GradientStop } from './core/colors';
export type { DisplayZone } from './digital/segments';
export {
clamp,
roundTo,
@ -64,28 +63,14 @@ export { VintageKnob, type VintageKnobProps } from './skins/VintageKnob';
export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob';
export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
export { PanKnob, type PanKnobProps } from './skins/PanKnob';
export { Fader, type FaderProps } from './skins/Fader';
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { Meter, type MeterProps } from './skins/Meter';
export { ampToDb, dbToAmp, type Ballistics } from './skins/meterEngine';
export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
export { Gauge, type GaugeProps } from './skins/Gauge';
export { PushButton, type PushButtonProps, Button, type ButtonProps } from './skins/PushButton';
export { PushButton, type PushButtonProps } from './skins/PushButton';
export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
export { LampRow, type LampRowProps, type LampRowItem } from './skins/LampRow';
export { ToggleSwitch, type ToggleSwitchProps } from './skins/ToggleSwitch';
export { SegmentSwitch, type SegmentSwitchProps } from './skins/SegmentSwitch';
export {
TransportButton,
type TransportButtonProps,
type TransportKind,
} from './skins/TransportButton';
export { ScrubField, type ScrubFieldProps } from './skins/ScrubField';
export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
// Layout
export { LabeledField, type LabeledFieldProps } from './layout/LabeledField';
export { Rack, type RackProps } from './layout/Rack';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,12 +19,6 @@ export interface IndicatorLampProps {
readOnly?: boolean;
/** Caption beside the lamp. */
label?: string;
/**
* Caption font size in px, independent of the lamp diameter. Without it the
* label tracks `size` but is floored at 10px; set this to shrink the caption
* with a small lamp (e.g. matching a dense header row's height).
*/
labelSize?: number;
labelPosition?: 'right' | 'left';
disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */
@ -51,7 +45,6 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
blink = false,
readOnly = false,
label,
labelSize,
labelPosition = 'right',
disabled = false,
name,
@ -173,7 +166,7 @@ export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampPr
<span
style={{
fontFamily: theme.fontUI,
fontSize: labelSize ?? Math.max(10, size * 0.9),
fontSize: Math.max(10, size * 0.9),
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: lit ? theme.text : theme.label,

View file

@ -15,13 +15,6 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
/**
* Stretch along the travel axis to fill the container (horizontal fills
* width, vertical fills height) instead of a fixed `length` the same
* responsive behavior as `Fader`, so faders in resizable dock panels size
* themselves instead of needing wrapper measurement.
*/
fill?: boolean;
/** Cross-axis size in px. Default: 34. */
breadth?: number;
/** Number of LED segments. Default: 24. */
@ -64,14 +57,13 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
orientation = 'vertical',
length = 160,
fill = false,
breadth = 34,
segments = 24,
color: colorProp,
zones,
offOpacity = 0.13,
glow = true,
faceColor: faceColorProp,
faceColor = '#0b0d0e',
focusRing,
label,
showValue = true,
@ -89,7 +81,6 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
const [editing, setEditing] = React.useState(false);
const theme = useKnobTheme();
const color = colorProp ?? theme.accent;
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const ring = focusRing ?? theme.focusRing;
const vertical = orientation === 'vertical';
const pad = 5;
@ -104,12 +95,6 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
const w = vertical ? breadth : length;
const h = vertical ? length : breadth;
// Fill stretches the travel axis (via preserveAspectRatio='none'); the cross
// axis stays at `breadth`. The LED geometry is computed in the fixed viewBox
// and scaled to the element — pointer mapping measures the real element, so
// interaction stays correct at any rendered width.
const ctrlW = fill && !vertical ? '100%' : w;
const ctrlH = fill && vertical ? '100%' : h;
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
if (color) return [{ upTo: 1, color }];
@ -172,8 +157,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
flexDirection: 'column',
alignItems: 'center',
gap: 6,
width: fill && !vertical ? '100%' : w,
height: fill && vertical ? '100%' : undefined,
width: w,
opacity: core.disabled ? 0.45 : undefined,
...style,
}}
@ -234,21 +218,14 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
style={{
...knob.bind.style,
opacity: undefined, // the outer wrapper owns disabled dimming
width: ctrlW,
height: ctrlH,
display: fill ? 'flex' : 'inline-flex',
width: w,
height: h,
display: 'inline-flex',
borderRadius: 6,
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
}}
>
<svg
width={ctrlW}
height={ctrlH}
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio={fill ? 'none' : undefined}
style={{ display: 'block' }}
aria-hidden="true"
>
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
<defs>
<filter
id={glowId}

View file

@ -90,10 +90,8 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
digits = 3,
displayDecimals,
labelColor,
faceColor: faceColorProp,
faceColor = '#0b0d0e',
label,
sublabel,
labelSize,
unit,
focusRing,
className,
@ -102,7 +100,6 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
}, ref) {
const theme = useKnobTheme();
const color = colorProp ?? theme.accent ?? '#3df2ad';
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const id = React.useId();
const glowId = `${id}-glow`;
return (
@ -153,8 +150,7 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
color={labelColor ?? theme.label}
fontFamily={theme.fontUI}
dy={size * 0.45}
fontSize={labelSize ?? size * 0.095}
sublabel={sublabel}
fontSize={size * 0.095}
>
{label}
</KnobLabel>

View file

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

View file

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

View file

@ -4,36 +4,19 @@ import type { Taper } from '../core/math';
import { useKnobTheme } from '../core/theme';
import type { LEDFaderZone } from './LEDFader';
import { SegmentDisplay } from '../digital/SegmentDisplay';
import { ampToDb, useMeterEngine, type Ballistics } from './meterEngine';
export interface MeterProps {
/**
* 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. With
* `scale="db"` these are linear amplitudes (0..1); otherwise they are in the
* `min`..`max` domain directly.
* meter (e.g. `[l, r]` for stereo). The meter is read-only.
*/
value: number | readonly number[];
min?: number;
max?: number;
taper?: Taper;
/**
* 'linear' (default) shows `value` in the `min`..`max` domain. 'db' treats
* `value`/`clipValue` as linear amplitude and displays dBFS; `min`/`max`
* then default to -60 / 0 dB.
*/
scale?: 'linear' | 'db';
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
/**
* Scale to fill the container instead of a fixed px footprint. The SVG keeps
* its `length`×`breadth` aspect ratio; a horizontal meter fills width, a
* vertical one fills height. Great for resizable dock panels.
*/
fill?: boolean;
/** Dim the meter uniformly (matches disabled controls). */
disabled?: boolean;
/** Cross-axis size in px PER CHANNEL. Minimum useful: ~6. Default: 16. */
breadth?: number;
/** Number of LED segments. Default: 28. */
@ -44,42 +27,16 @@ export interface MeterProps {
zones?: readonly LEDFaderZone[];
/** Opacity of unlit segments. Default: 0.1. */
offOpacity?: number;
/**
* Attack/decay ballistics smooth the displayed level like a PPM meter.
* `true` uses ~5 ms attack / ~350 ms decay; pass `{ attack, decay }` (ms) to
* tune. Default: off (the level follows the value directly).
*/
ballistics?: boolean | Ballistics;
/**
* Peak hold time in ms the highest recent segment stays lit and falls
* back after this long. `false` disables. Default: 1200.
*/
peakHold?: number | false;
/**
* After `peakHold`, let the peak marker fall at this many normalized units
* per second (a decay tail) instead of snapping back. Requires the rAF
* engine (also enabled by `ballistics`/`integrated`).
*/
peakDecay?: number;
/** Peak indicator color. Defaults to the peak's zone color. */
peakColor?: string;
/**
* Show a time-windowed loudness estimate (LUFS-ish on the 'db' scale a
* mean of channel power over `integrationWindow`, not a full BS.1770
* measurement). Rendered as a small secondary readout + a marker line.
*/
integrated?: boolean;
/** Loudness integration window in ms. Default: 3000. */
integrationWindow?: number;
/** Color for the integrated-loudness readout and marker line. */
loudnessColor?: string;
/** Faint reference gridlines across the meter. Default: on for scale='db'. */
showScale?: boolean;
/** Reference marks in the value domain. Defaults to dBFS marks for 'db'. */
referenceTicks?: readonly number[];
/** Dedicated clip LED at the hot end (shared across channels). Default: true. */
showClip?: boolean;
/** Value (in the display domain — dB for scale='db') at/above which it lights. Default: `max`. */
/** Value at/above which the clip LED lights. Default: `max`. */
clipThreshold?: number;
/**
* Signal(s) used for clip detection when different from the displayed
@ -113,45 +70,31 @@ export interface MeterProps {
const asArray = (v: number | readonly number[]): readonly number[] =>
Array.isArray(v) ? v : [v as number];
const DEFAULT_DB_TICKS = [0, -6, -12, -18, -24, -36, -48];
/**
* Read-only LED level meter with peak hold and a latching clip LED pass an
* array of values for stereo/multi-channel bars sharing one clip indicator.
* Optional PPM ballistics, a peak-decay tail, a dBFS scale and a windowed
* loudness readout (all opt-in).
*/
export const Meter: React.FC<MeterProps> = ({
value,
min: minProp,
max: maxProp,
min = 0,
max = 100,
taper = linearTaper,
scale = 'linear',
orientation = 'vertical',
length = 160,
fill = false,
disabled = false,
breadth = 16,
segments = 28,
color,
zones,
offOpacity = 0.1,
ballistics,
peakHold = 1200,
peakDecay,
peakColor,
integrated = false,
integrationWindow = 3000,
loudnessColor,
showScale,
referenceTicks,
showClip = true,
clipThreshold,
clipValue,
clipHold = 1500,
clipColor = '#ff2b39',
onClip,
faceColor: faceColorProp,
faceColor = '#0b0d0e',
label,
showValue = false,
digits = 4,
@ -163,51 +106,27 @@ export const Meter: React.FC<MeterProps> = ({
...aria
}) => {
const theme = useKnobTheme();
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
const vertical = orientation === 'vertical';
const dbScale = scale === 'db';
const min = minProp ?? (dbScale ? -60 : 0);
const max = maxProp ?? (dbScale ? 0 : 100);
const values = asArray(value);
const channels = values.length;
// Value-domain per channel (dB for scale='db', else the value itself).
const domain = values.map(v => (dbScale ? ampToDb(v) : v));
const targetNs = domain.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1));
const ns = values.map(v => clamp(taper.toNormalized(clamp(v, min, max), min, max), 0, 1));
// rAF engine (ballistics / peak-decay / loudness). Null on the legacy path.
const animated = !!ballistics || peakDecay != null || integrated;
const engine = useMeterEngine(targetNs, domain, {
enabled: animated,
ballistics,
peakHold,
peakDecay: peakDecay ?? null,
integrated,
integrationWindow,
scale,
});
const levels = animated && engine ? engine.levels : targetNs;
// Legacy timer-based peaks (only when the engine is off).
const [legacyPeaks, setLegacyPeaks] = React.useState<readonly number[]>(targetNs);
// Peaks: per channel, falling back together after `peakHold` ms of no rise.
const [peaks, setPeaks] = React.useState<readonly number[]>(ns);
React.useEffect(() => {
if (animated || peakHold === false) return;
setLegacyPeaks(prev => {
const rose = targetNs.map((n, i) => n >= (prev[i] ?? 0));
if (rose.every(Boolean) || prev.length !== targetNs.length)
return targetNs.map((n, i) => Math.max(n, prev[i] ?? 0));
return prev.map((p, i) => Math.max(p, targetNs[i] ?? 0));
if (peakHold === false) return;
setPeaks(prev => {
const rose = ns.map((n, i) => n >= (prev[i] ?? 0));
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(() => setLegacyPeaks(targetNs), peakHold);
const t = setTimeout(() => setPeaks(ns), peakHold);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(targetNs), peakHold, animated]);
const peaks = animated && engine ? engine.peaks : legacyPeaks;
}, [JSON.stringify(ns), peakHold]);
// Clip: latch when any channel's raw (unclamped) signal reaches threshold.
const clipSignals = (clipValue !== undefined ? asArray(clipValue) : values).map(v =>
dbScale ? ampToDb(v) : v,
);
const clipSignals = clipValue !== undefined ? asArray(clipValue) : values;
const hottest = Math.max(...clipSignals);
const [clipped, setClipped] = React.useState(false);
const clipTimer = React.useRef<ReturnType<typeof setTimeout>>();
@ -253,20 +172,14 @@ export const Meter: React.FC<MeterProps> = ({
const clipLed = 7;
const clipSpan = showClip ? clipLed + 3 : 0;
const innerLen = along - pad * 2 - clipSpan;
const slot = Math.max(1, innerLen / segments);
const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments);
const gap = Math.min(2.5, slot * 0.35);
const segSize = Math.max(0.5, slot - gap);
// Position (px along the travel axis) of a normalized value, measured from
// the cold end.
const posOf = (nrm: number) =>
vertical ? along - pad - nrm * innerLen : pad + nrm * innerLen;
const bars: React.ReactNode[] = [];
for (let c = 0; c < channels; c++) {
const offset = c * (breadth + chGap) + pad;
const lit = Math.round((levels[c] ?? 0) * segments);
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)
@ -275,8 +188,8 @@ export const Meter: React.FC<MeterProps> = ({
const zone = zoneFor((i + 1) / segments);
const isPeak = i === peakIdx && i >= lit;
const on = i < lit || isPeak;
const barFill = isPeak ? (peakColor ?? zone) : zone;
const common = { rx: 1.2, fill: barFill, opacity: on ? 1 : offOpacity };
const fill = isPeak ? (peakColor ?? zone) : zone;
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
bars.push(
vertical ? (
<rect
@ -301,43 +214,7 @@ export const Meter: React.FC<MeterProps> = ({
}
}
// Reference gridlines (value domain -> position across the bar area).
const ticks = referenceTicks ?? (dbScale ? DEFAULT_DB_TICKS : []);
const drawScale = (showScale ?? dbScale) && ticks.length > 0;
const scaleLines: React.ReactNode[] = [];
if (drawScale) {
for (const tv of ticks) {
if (tv < min || tv > max) continue;
const nrm = clamp(taper.toNormalized(clamp(tv, min, max), min, max), 0, 1);
const p = posOf(nrm);
scaleLines.push(
vertical ? (
<line key={`s${tv}`} x1={pad} y1={p} x2={w - pad} y2={p} stroke="rgba(255,255,255,0.14)" strokeWidth={0.6} strokeDasharray="2 2" />
) : (
<line key={`s${tv}`} x1={p} y1={pad} x2={p} y2={h - pad} stroke="rgba(255,255,255,0.14)" strokeWidth={0.6} strokeDasharray="2 2" />
),
);
}
}
// Integrated-loudness marker line.
const loud = loudnessColor ?? theme.zoneWarn;
let loudnessLine: React.ReactNode = null;
if (integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated)) {
const nrm = clamp(taper.toNormalized(clamp(engine.integrated, min, max), min, max), 0, 1);
if (nrm > 0.001) {
const p = posOf(nrm);
loudnessLine = vertical ? (
<line x1={pad} y1={p} x2={w - pad} y2={p} stroke={loud} strokeWidth={1.4} />
) : (
<line x1={p} y1={pad} x2={p} y2={h - pad} stroke={loud} strokeWidth={1.4} />
);
}
}
const shownDomain = domain.length ? domain.map(d => clamp(d, min, max)) : [min];
const hottestShown = Math.max(...shownDomain);
const readoutColor = zoneFor(Math.max(...levels, 0));
const hottestShown = Math.max(...values.map(v => clamp(v, min, max)));
return (
<div
@ -348,70 +225,36 @@ export const Meter: React.FC<MeterProps> = ({
aria-valuenow={hottestShown}
aria-label={aria['aria-label']}
data-clipped={clipped ? '' : undefined}
data-disabled={disabled ? '' : undefined}
style={{
display: fill ? 'flex' : 'inline-flex',
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 5,
width: fill && !vertical ? '100%' : w,
height: fill && vertical ? '100%' : undefined,
opacity: disabled ? 0.45 : undefined,
width: w,
...style,
}}
>
{(showValue || (integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated))) && (
// Each readout is its own stack: the value with its unit centered
// directly underneath — peak above, loudness below.
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
{showValue && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<div style={{ width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: 4 }}>
<SegmentDisplay
value={hottestShown}
digits={digits}
decimals={displayDecimals}
height={11}
color={readoutColor}
color={zoneFor(Math.max(...ns))}
background="none"
ghostOpacity={0}
ghostOpacity={0.06}
/>
{unit && (
<span style={{ fontFamily: theme.fontMono, fontSize: 9, letterSpacing: '0.06em', color: theme.label, whiteSpace: 'nowrap' }}>
<span style={{ fontFamily: theme.fontMono, fontSize: 10, color: theme.label, whiteSpace: 'nowrap' }}>
{unit}
</span>
)}
</div>
)}
{integrated && engine && engine.integrated != null && Number.isFinite(engine.integrated) && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<SegmentDisplay
value={clamp(engine.integrated, min, max)}
digits={digits}
decimals={displayDecimals}
height={9}
color={loud}
background="none"
ghostOpacity={0}
/>
<span style={{ fontFamily: theme.fontMono, fontSize: 9, letterSpacing: '0.06em', color: theme.label, whiteSpace: 'nowrap' }}>
{dbScale ? 'LUFS' : 'AVG'}
</span>
</div>
)}
</div>
)}
<svg
width={fill && !vertical ? '100%' : w}
height={fill && vertical ? '100%' : h}
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio={fill ? (vertical ? 'xMidYMax meet' : 'xMinYMid meet') : undefined}
style={{ display: 'block' }}
aria-hidden="true"
>
<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)" />
{scaleLines}
{bars}
{loudnessLine}
{showClip && (
<rect
data-part="clip"

View file

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

View file

@ -1,60 +0,0 @@
import * as React from 'react';
import { FlatKnob, type FlatKnobProps } from './FlatKnob';
export interface PanKnobProps extends Omit<FlatKnobProps, 'arcFrom'> {
/**
* Readout precision for the L/R amount. Default: 0 (whole numbers).
* The formatter shows `C` at center, `L<n>` left, `R<n>` right.
*/
panDecimals?: number;
}
/**
* A stereo pan control a bipolar knob that fills from the center, snaps to a
* center detent, and reads out `L<n>` / `C` / `R<n>`. Defaults to a 5050
* range centered at 0; pass `min`/`max` for other conventions (e.g. 11).
*/
export const PanKnob = React.forwardRef<HTMLDivElement, PanKnobProps>(function PanKnob(
{
min = -50,
max = 50,
defaultValue,
origin,
detents,
detentSize,
format,
label = 'Pan',
panDecimals = 0,
...rest
},
ref,
) {
const center = (min + max) / 2;
const panFormat = React.useMemo(
() =>
format ??
((v: number) => {
const amount = Math.abs(v - center);
if (amount < Math.pow(10, -panDecimals) / 2) return 'C';
const n = amount.toFixed(panDecimals);
return v < center ? `L${n}` : `R${n}`;
}),
[format, center, panDecimals],
);
return (
<FlatKnob
ref={ref}
arcFrom="center"
min={min}
max={max}
defaultValue={defaultValue ?? center}
origin={origin ?? center}
detents={detents ?? [center]}
detentSize={detentSize ?? 0.04}
format={panFormat}
label={label}
{...rest}
/>
);
});

View file

@ -1,35 +1,19 @@
import * as React from 'react';
import { panelButtonChrome, useKnobTheme } from '../core/theme';
import { useKnobTheme } from '../core/theme';
/** Call every handler in order (skipping undefined) for merging our own
* handlers with consumer/Radix-injected ones on the same event. */
const chain =
<E,>(...fns: Array<((e: E) => void) | undefined>) =>
(e: E) => {
for (const fn of fns) if (typeof fn === 'function') fn(e);
};
export interface PushButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'onClick'> {
export interface PushButtonProps {
/** Controlled pressed state. Pair with `onChange`. */
pressed?: boolean;
defaultPressed?: boolean;
onChange?: (pressed: boolean) => void;
/**
* 'toggle' latches; 'momentary' is on only while held; 'action' is a normal
* click-to-fire command button it never latches, shows a press-down + a
* brief LED flash, and calls `onClick`. Default: 'toggle'.
*/
mode?: 'toggle' | 'momentary' | 'action';
/** Fired on activation in `action` mode (click, Enter, or Space). */
onClick?: () => void;
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
mode?: 'toggle' | 'momentary';
/** LED / active color. */
color?: string;
/**
* LED style: 'strip' (above the caption), 'dot' (small corner dot scales
* down better for icon-only toggles), or false for none. `true` is an alias
* for 'strip'. Defaults to 'strip' for toggle/momentary (they indicate a
* state) and to false for `mode="action"` (a command has no state to show).
* 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. */
@ -39,12 +23,11 @@ export interface PushButtonProps
disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */
name?: string;
/** Icon rendered before the caption (e.g. a lucide glyph), tinted to the LED. */
leadingIcon?: React.ReactNode;
/** Caption, e.g. "MUTE". */
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/** Studio panel button with an LED strip — mute/solo/bypass in matching style. */
@ -54,69 +37,35 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
pressed,
defaultPressed = false,
onChange,
onClick,
mode = 'toggle',
color,
led: ledProp,
led = true,
size = 36,
width,
disabled = false,
name,
leadingIcon,
children,
className,
style,
...rest
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
// The LED indicates a persistent/held state, so it defaults on for
// toggle/momentary but off for `action` (a command has no state).
const led = ledProp ?? (mode === 'action' ? false : true);
const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed);
const [focusVisible, setFocusVisible] = React.useState(false);
// Action mode is stateless: a transient `held` (pointer down) drives the
// press-down look and a short `flash` lights the LED on activation.
const [held, setHeld] = React.useState(false);
const [flash, setFlash] = React.useState(false);
const flashTimer = React.useRef<ReturnType<typeof setTimeout>>();
React.useEffect(() => () => clearTimeout(flashTimer.current), []);
const latched = isControlled ? (pressed as boolean) : internal;
const on = mode === 'action' ? held : latched; // press-down / background inset
const ledOn = mode === 'action' ? held || flash : latched;
const chrome = panelButtonChrome(theme.scheme, on);
// Unlit LED tint: faint white on dark chrome, faint black on light.
const ledOff = theme.scheme === 'light' ? 'rgba(0,0,0,0.13)' : 'rgba(255,255,255,0.09)';
const on = isControlled ? (pressed as boolean) : internal;
const set = (next: boolean) => {
if (next === latched) return;
if (next === on) return;
if (!isControlled) setInternal(next);
onChange?.(next);
};
const fire = () => {
onClick?.();
setFlash(true);
clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setFlash(false), 160);
};
const momentaryProps =
mode === 'action'
? {
onClick: fire,
onPointerDown: (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
setHeld(true);
},
onPointerUp: () => setHeld(false),
onPointerCancel: () => setHeld(false),
onPointerLeave: () => setHeld(false),
}
: mode === 'momentary'
mode === 'momentary'
? {
onPointerDown: (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
@ -131,40 +80,27 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
if (e.key === ' ' || e.key === 'Enter') set(false);
},
}
: { onClick: () => set(!latched) };
: { onClick: () => set(!on) };
// Merge our own handlers with any consumer/Radix-injected ones of the same
// name (from {...rest}), so the button works as a Radix `asChild` trigger:
// the injected onPointerDown/onKeyDown/data-state/aria-* all reach the DOM.
const restHandlers = rest as Record<string, ((e: unknown) => void) | undefined>;
const composedHandlers = Object.fromEntries(
Object.entries(momentaryProps).map(([k, fn]) => [
k,
chain(restHandlers[k], fn as (e: unknown) => void),
]),
);
const handleFocus = chain(restHandlers.onFocus, (e: React.FocusEvent<HTMLButtonElement>) => {
return (
<button
ref={ref}
type="button"
disabled={disabled}
aria-pressed={on}
aria-label={aria['aria-label']}
data-pressed={on ? '' : undefined}
data-focus-visible={focusVisible ? '' : undefined}
className={className}
onFocus={e => {
try {
setFocusVisible(e.currentTarget.matches(':focus-visible'));
} catch {
setFocusVisible(true);
}
});
const handleBlur = chain(restHandlers.onBlur, () => setFocusVisible(false));
return (
<button
{...rest}
ref={ref}
type="button"
disabled={disabled}
aria-pressed={mode === 'action' ? undefined : latched}
data-pressed={on ? '' : undefined}
data-focus-visible={focusVisible ? '' : undefined}
className={className}
onFocus={handleFocus}
onBlur={handleBlur}
{...composedHandlers}
}}
onBlur={() => setFocusVisible(false)}
{...momentaryProps}
style={{
position: 'relative',
display: 'inline-flex',
@ -177,12 +113,16 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
minWidth: size * 1.4,
padding: '0 12px',
borderRadius: 7,
border: chrome.border,
background: chrome.background,
border: '1px solid rgba(0,0,0,0.7)',
background: on
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
outline: 'none',
boxShadow: `${chrome.boxShadow}${
focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''
}`,
boxShadow: `${
on
? 'inset 0 2px 5px rgba(0,0,0,0.65)'
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)'
}${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
color: on ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.28),
@ -205,8 +145,8 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width: '55%',
height: Math.max(3, size * 0.09),
borderRadius: 3,
background: ledOn ? accent : ledOff,
boxShadow: ledOn ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
background: on ? accent : 'rgba(255,255,255,0.09)',
boxShadow: on ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms',
}}
/>
@ -221,49 +161,15 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
width: Math.max(4, size * 0.14),
height: Math.max(4, size * 0.14),
borderRadius: '50%',
background: ledOn ? accent : ledOff,
boxShadow: ledOn ? `0 0 ${size * 0.25}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
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',
}}
/>
)}
{(leadingIcon || children) && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, lineHeight: 1 }}>
{leadingIcon && (
<span
aria-hidden="true"
style={{
display: 'inline-flex',
color: ledOn ? accent : theme.label,
transition: 'color 80ms',
}}
>
{leadingIcon}
</span>
)}
{children}
</span>
)}
{name && <input type="hidden" name={name} value={latched ? 'on' : 'off'} readOnly />}
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
</button>
);
},
);
export interface ButtonProps
extends Omit<PushButtonProps, 'mode' | 'pressed' | 'defaultPressed' | 'onChange'> {
onClick?: () => void;
}
/**
* A normal click-to-fire command button in the studio-panel style the
* non-latching sibling of `PushButton`. Use it for actions (Regenerate,
* Add, Apply, Refresh). It's `PushButton` locked to `mode="action"`. Plain by
* default (no LED an action has no state); pass `led="dot"` for a corner dot
* that flashes on press, or `led="strip"` for the full strip.
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button({ led, ...rest }, ref) {
return <PushButton ref={ref} mode="action" led={led} {...rest} />;
},
);

View file

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

View file

@ -46,7 +46,6 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
const light = theme.scheme === 'light';
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));
@ -89,13 +88,9 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
display: 'inline-flex',
height: size,
borderRadius: 8,
border: light ? '1px solid rgba(0,0,0,0.14)' : '1px solid rgba(0,0,0,0.7)',
background: light
? 'linear-gradient(180deg, #e3e4e8, #edeef1)'
: 'linear-gradient(180deg, #16171c, #101116)',
boxShadow: light
? 'inset 0 1px 2px rgba(0,0,0,0.1)'
: 'inset 0 1px 0 rgba(255,255,255,0.06), 0 2px 4px rgba(0,0,0,0.4)',
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',
@ -127,20 +122,11 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
padding: '0 12px',
minWidth: size * 1.6,
border: 'none',
borderRight:
i < options.length - 1
? `1px solid ${light ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.55)'}`
: 'none',
borderRight: i < options.length - 1 ? '1px solid rgba(0,0,0,0.55)' : 'none',
background: isActive
? light
? 'linear-gradient(180deg, #ffffff, #f4f5f7)'
: 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
? 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
: 'transparent',
boxShadow: isActive
? light
? '0 1px 2px rgba(0,0,0,0.16)'
: 'inset 0 2px 4px rgba(0,0,0,0.55)'
: undefined,
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),
@ -159,7 +145,7 @@ export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps
width: '55%',
height: Math.max(2, size * 0.08),
borderRadius: 2,
background: isActive ? accent : light ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.08)',
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',
}}

View file

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

View file

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

View file

@ -1,180 +0,0 @@
import * as React from 'react';
import { panelButtonChrome, useKnobTheme } from '../core/theme';
export type TransportKind =
| 'play'
| 'pause'
| 'stop'
| 'record'
| 'rewind'
| 'forward'
| 'panic';
export interface TransportButtonProps {
kind: TransportKind;
/** Engaged/lit state (playing, armed, recording). Reflects app state. */
active?: boolean;
onClick?: () => void;
/** Pulse the glyph while active (auto-on for `record`). Reduced-motion aware. */
blink?: boolean;
/** Override the glyph/LED color. Defaults per kind (play green, record red…). */
color?: string;
/** Button size in px (square). Default: 38. */
size?: number;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
const LABEL: Record<TransportKind, string> = {
play: 'Play',
pause: 'Pause',
stop: 'Stop',
record: 'Record',
rewind: 'Rewind',
forward: 'Forward',
panic: 'Panic — all notes off',
};
/** Glyph in a 24×24 box, filled with `currentColor`. */
const Glyph: React.FC<{ kind: TransportKind }> = ({ kind }) => {
switch (kind) {
case 'play':
return <path d="M8 5.5 L19 12 L8 18.5 Z" />;
case 'pause':
return (
<>
<rect x="7" y="5.5" width="3.6" height="13" rx="1" />
<rect x="13.4" y="5.5" width="3.6" height="13" rx="1" />
</>
);
case 'stop':
return <rect x="6.5" y="6.5" width="11" height="11" rx="1.5" />;
case 'record':
return <circle cx="12" cy="12" r="5.2" />;
case 'rewind':
return (
<>
<path d="M11.5 6 L11.5 18 L4 12 Z" />
<path d="M20 6 L20 18 L12.5 12 Z" />
</>
);
case 'forward':
return (
<>
<path d="M4 6 L11.5 12 L4 18 Z" />
<path d="M12.5 6 L20 12 L12.5 18 Z" />
</>
);
case 'panic':
return (
<>
<rect x="10.6" y="5" width="2.8" height="8.4" rx="1.4" />
<circle cx="12" cy="17.4" r="1.7" />
</>
);
}
};
/**
* A record / play / stop / pause / panic transport button, styled to sit
* alongside dreamknob controls. `active` lights it; `record` blinks while
* armed. Reduced-motion aware.
*/
export const TransportButton = React.forwardRef<HTMLButtonElement, TransportButtonProps>(
function TransportButton(
{ kind, active = false, onClick, blink, color, size = 38, disabled = false, className, style, ...aria },
ref,
) {
const theme = useKnobTheme();
const [focusVisible, setFocusVisible] = React.useState(false);
const glyphRef = React.useRef<SVGSVGElement>(null);
const chrome = panelButtonChrome(theme.scheme, active);
const accent =
color ??
(kind === 'record' || kind === 'panic'
? theme.zoneHot
: kind === 'play'
? theme.zoneGood
: kind === 'pause'
? theme.zoneWarn
: theme.text);
const lit = active ? accent : theme.label;
const shouldBlink = (blink ?? kind === 'record') && active;
React.useEffect(() => {
const el = glyphRef.current;
if (!el || !shouldBlink) return;
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
return;
const anim = el.animate([{ opacity: 1 }, { opacity: 0.3 }], {
duration: 560,
iterations: Infinity,
direction: 'alternate',
easing: 'ease-in-out',
});
return () => anim.cancel();
}, [shouldBlink]);
return (
<button
ref={ref}
type="button"
aria-label={aria['aria-label'] ?? LABEL[kind]}
aria-pressed={active}
disabled={disabled}
data-active={active ? '' : undefined}
data-kind={kind}
data-focus-visible={focusVisible ? '' : undefined}
className={className}
onClick={onClick}
onFocus={e => {
try {
setFocusVisible(e.currentTarget.matches(':focus-visible'));
} catch {
setFocusVisible(true);
}
}}
onBlur={() => setFocusVisible(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: size,
height: size,
padding: 0,
borderRadius: 8,
border: chrome.border,
background: chrome.background,
boxShadow: `${chrome.boxShadow}${
active ? `, 0 0 ${size * 0.28}px ${accent}55` : ''
}${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
outline: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.45 : 1,
touchAction: 'manipulation',
transition: 'background 80ms, box-shadow 80ms',
...style,
}}
>
<svg
ref={glyphRef}
width={size * 0.55}
height={size * 0.55}
viewBox="0 0 24 24"
fill={lit}
style={{
display: 'block',
filter: active ? `drop-shadow(0 0 ${size * 0.12}px ${accent})` : undefined,
transition: 'fill 80ms',
}}
aria-hidden="true"
>
<Glyph kind={kind} />
</svg>
</button>
);
},
);

View file

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

View file

@ -1,45 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ampToDb, dbToAmp, smoothingCoeff } from './meterEngine';
describe('ampToDb', () => {
it('maps unity amplitude to 0 dBFS', () => {
expect(ampToDb(1)).toBeCloseTo(0, 6);
});
it('halving amplitude is about -6 dB', () => {
expect(ampToDb(0.5)).toBeCloseTo(-6.0206, 3);
});
it('a tenth is -20 dB', () => {
expect(ampToDb(0.1)).toBeCloseTo(-20, 6);
});
it('silence is -Infinity', () => {
expect(ampToDb(0)).toBe(-Infinity);
expect(ampToDb(-0.2)).toBe(-Infinity);
});
it('round-trips through dbToAmp', () => {
for (const amp of [1, 0.5, 0.25, 0.031]) {
expect(dbToAmp(ampToDb(amp))).toBeCloseTo(amp, 6);
}
});
});
describe('smoothingCoeff', () => {
it('is in (0,1) and rises with dt', () => {
const a = smoothingCoeff(5, 100);
const b = smoothingCoeff(50, 100);
expect(a).toBeGreaterThan(0);
expect(b).toBeLessThan(1);
expect(b).toBeGreaterThan(a);
});
it('reaches ~63% of the way after one time constant', () => {
expect(smoothingCoeff(100, 100)).toBeCloseTo(1 - Math.exp(-1), 6);
});
it('a shorter tau (faster attack) moves further per step', () => {
expect(smoothingCoeff(10, 5)).toBeGreaterThan(smoothingCoeff(10, 350));
});
});

View file

@ -1,167 +0,0 @@
import * as React from 'react';
/** Linear amplitude (0..1+) to dBFS. 0 (or below) maps to -Infinity. */
export const ampToDb = (amp: number): number => (amp <= 0 ? -Infinity : 20 * Math.log10(amp));
/** dBFS back to linear amplitude. */
export const dbToAmp = (db: number): number => Math.pow(10, db / 20);
/** One-pole smoothing coefficient for a time constant `tau` (ms) over `dt` (ms). */
export const smoothingCoeff = (dt: number, tau: number): number =>
1 - Math.exp(-dt / Math.max(1, tau));
export interface Ballistics {
/** Rise time constant in ms (fast). */
attack?: number;
/** Fall time constant in ms (slow). */
decay?: number;
}
export interface MeterEngineOptions {
/** Run the rAF engine at all. When false the hook returns null (legacy path). */
enabled: boolean;
/** Smooth the displayed level with attack/decay ballistics. */
ballistics?: boolean | Ballistics;
/** Peak-hold time in ms before the peak marker starts to fall. */
peakHold?: number | false;
/** Peak fall rate in normalized units per second once the hold expires. */
peakDecay?: number | null;
/** Compute a time-windowed loudness (LUFS-ish) from `rawDomain`. */
integrated?: boolean;
/** Loudness integration window in ms. */
integrationWindow?: number;
/** 'db' integrates power in the log domain; 'linear' uses RMS. */
scale?: 'linear' | 'db';
}
export interface MeterEngineState {
/** Ballistics-smoothed (or raw) normalized levels, per channel. */
levels: readonly number[];
/** Peak-hold markers, per channel, in normalized [0,1]. */
peaks: readonly number[];
/** Windowed loudness in the value domain (dB for 'db' scale), or null. */
integrated: number | null;
}
const DEFAULT_ATTACK = 5;
const DEFAULT_DECAY = 350;
/**
* A requestAnimationFrame meter engine: attack/decay ballistics, peak-hold with
* a decay tail, and an optional time-windowed loudness estimate. Returns null
* when disabled so the caller can keep its cheap legacy (timer) path.
*
* `targetNs` are the instantaneous normalized levels; `rawDomain` are the same
* channels in value space (dB or linear) for loudness integration.
*/
export const useMeterEngine = (
targetNs: readonly number[],
rawDomain: readonly number[],
opts: MeterEngineOptions,
): MeterEngineState | null => {
const {
enabled,
ballistics,
peakHold = 1200,
peakDecay = null,
integrated = false,
integrationWindow = 3000,
scale = 'linear',
} = opts;
const useBallistics = !!ballistics;
const attack = (typeof ballistics === 'object' && ballistics?.attack) || DEFAULT_ATTACK;
const decay = (typeof ballistics === 'object' && ballistics?.decay) || DEFAULT_DECAY;
const targetRef = React.useRef(targetNs);
targetRef.current = targetNs;
const rawRef = React.useRef(rawDomain);
rawRef.current = rawDomain;
const [state, setState] = React.useState<MeterEngineState>(() => ({
levels: targetNs,
peaks: targetNs,
integrated: null,
}));
// Per-channel timestamp of the last time the peak was refreshed.
const holdAt = React.useRef<number[]>([]);
// Ring buffer of {t, power} for loudness integration.
const powerBuf = React.useRef<{ t: number; p: number }[]>([]);
React.useEffect(() => {
if (!enabled) return;
let raf = 0;
let last =
typeof performance !== 'undefined' && performance.now ? performance.now() : 0;
const tick = (now: number) => {
const dt = Math.min(100, now - last);
last = now;
const tgt = targetRef.current;
const raw = rawRef.current;
setState(prev => {
const n = tgt.length;
const prevLevels = prev.levels.length === n ? prev.levels : tgt;
const prevPeaks = prev.peaks.length === n ? prev.peaks : tgt;
const levels = tgt.map((t, i) => {
const l = prevLevels[i] ?? 0;
if (!useBallistics) return t;
const tau = t >= l ? attack : decay;
return l + (t - l) * smoothingCoeff(dt, tau);
});
const holdMs = peakHold === false ? Infinity : peakHold;
const peaks = levels.map((lvl, i) => {
const p = prevPeaks[i] ?? 0;
if (lvl >= p) {
holdAt.current[i] = now;
return lvl;
}
const heldFor = now - (holdAt.current[i] ?? now);
if (heldFor < holdMs) return p;
if (peakDecay == null) return lvl; // no decay tail — track the level
return Math.max(lvl, p - (peakDecay / 1000) * dt);
});
let integratedOut = prev.integrated;
if (integrated) {
const power =
raw.length > 0
? raw.reduce(
(s, d) => s + (scale === 'db' ? Math.pow(10, d / 10) : d * d),
0,
) / raw.length
: 0;
const buf = powerBuf.current;
buf.push({ t: now, p: power });
while (buf.length > 1 && now - buf[0].t > integrationWindow) buf.shift();
const meanP = buf.reduce((s, b) => s + b.p, 0) / (buf.length || 1);
integratedOut =
scale === 'db' ? 10 * Math.log10(Math.max(meanP, 1e-12)) : Math.sqrt(meanP);
}
return { levels, peaks, integrated: integratedOut };
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [
enabled,
useBallistics,
attack,
decay,
peakHold,
peakDecay,
integrated,
integrationWindow,
scale,
]);
return enabled ? state : null;
};

View file

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