Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b462dfb83 | |||
| d183d7d16b | |||
| 605c38af51 | |||
| a6348d5c46 | |||
| 89c7a7f603 | |||
| 5b97091902 | |||
| d8b7b55505 |
50 changed files with 4101 additions and 394 deletions
|
|
@ -35,8 +35,11 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
|
||||||
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
|
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
|
||||||
power / custom tapers.
|
power / custom tapers.
|
||||||
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
|
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
|
||||||
`SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `XYPad`,
|
`SteppedKnob`, `PanKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`
|
||||||
`PushButton`, `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance.
|
(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.
|
||||||
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
|
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
|
||||||
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
|
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
|
||||||
`useKnob`.
|
`useKnob`.
|
||||||
|
|
@ -48,7 +51,7 @@ See `packages/dreamknob/README.md` and the docs app for the full API.
|
||||||
The easiest way is the published release on our Forgejo:
|
The easiest way is the published release on our Forgejo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz
|
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz
|
||||||
```
|
```
|
||||||
|
|
||||||
Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types):
|
Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AdvancedGuide } from './sections/AdvancedGuide';
|
import { AdvancedGuide } from './sections/AdvancedGuide';
|
||||||
import { ApiDocs } from './sections/ApiDocs';
|
import { ApiDocs } from './sections/ApiDocs';
|
||||||
|
import { Buttons } from './sections/Buttons';
|
||||||
import { Console } from './sections/Console';
|
import { Console } from './sections/Console';
|
||||||
import { Gallery } from './sections/Gallery';
|
import { Gallery } from './sections/Gallery';
|
||||||
import { GettingStarted } from './sections/GettingStarted';
|
import { GettingStarted } from './sections/GettingStarted';
|
||||||
|
|
@ -26,6 +27,7 @@ export const App: React.FC = () => (
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<a href="#start">Start</a>
|
<a href="#start">Start</a>
|
||||||
<a href="#gallery">Gallery</a>
|
<a href="#gallery">Gallery</a>
|
||||||
|
<a href="#buttons">Buttons</a>
|
||||||
<a href="#synth">Synth</a>
|
<a href="#synth">Synth</a>
|
||||||
<a href="#playground">Playground</a>
|
<a href="#playground">Playground</a>
|
||||||
<a href="#guide">Guide</a>
|
<a href="#guide">Guide</a>
|
||||||
|
|
@ -36,6 +38,7 @@ export const App: React.FC = () => (
|
||||||
<Hero />
|
<Hero />
|
||||||
<GettingStarted />
|
<GettingStarted />
|
||||||
<Gallery />
|
<Gallery />
|
||||||
|
<Buttons />
|
||||||
<Console />
|
<Console />
|
||||||
<Synth />
|
<Synth />
|
||||||
<Playground />
|
<Playground />
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,9 @@ export const ApiDocs: React.FC = () => (
|
||||||
<Row name="wheelStep" type="number" def="step (else 1% of range)" desc="Value change per wheel notch, independent of the snapping step." />
|
<Row name="wheelStep" type="number" def="step (else 1% of range)" desc="Value change per wheel notch, independent of the snapping step." />
|
||||||
<Row name="wheelRequiresFocus" type="boolean" def="false" desc="Only respond to the wheel while focused — hovering no longer captures page scroll." />
|
<Row name="wheelRequiresFocus" type="boolean" def="false" desc="Only respond to the wheel while focused — hovering no longer captures page scroll." />
|
||||||
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
|
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
|
||||||
<Row name="onChange" type="(v, meta) => void" desc="Fires for every distinct snapped value. meta.source tells you what caused it: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'." />
|
<Row name="onChange" type="(v, meta) => void" desc="Fires for every distinct snapped value. meta.source tells you what caused it: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'edit' | 'api'." />
|
||||||
<Row name="onChangeStart / onChangeEnd" type="(v, meta) => void" desc="Gesture boundaries (drag, wheel burst, key press, reset) — ideal for undo history or parameter automation." />
|
<Row name="onChangeStart / onChangeEnd" type="(v, meta) => void" desc="Gesture boundaries (drag, wheel burst, key press, reset, type-in edit) — ideal for undo history or parameter automation." />
|
||||||
|
<Row name="commitMode" type="'live' | 'release'" def="'live'" desc="'release' previews drags visually but emits onChange only once, on pointer-up — for expensive parameters (reverb sizes, engine restarts). Wheel/keyboard still emit live; Esc discards the preview." />
|
||||||
<Row name="◇ name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work (XYPad uses nameX/nameY)." />
|
<Row name="◇ name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work (XYPad uses nameX/nameY)." />
|
||||||
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
|
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
|
||||||
<Row name="◇ editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." />
|
<Row name="◇ editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." />
|
||||||
|
|
@ -87,29 +88,74 @@ export const ApiDocs: React.FC = () => (
|
||||||
via pointer capture · every control shows a keyboard-only focus ring.
|
via pointer capture · every control shows a keyboard-only focus ring.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3 className="api-h">The change contract</h3>
|
||||||
|
<p className="api-note">
|
||||||
|
Every way a value can move maps to a <code>ChangeMeta.source</code>, and every
|
||||||
|
user gesture is bracketed by exactly one <code>onChangeStart</code> /{' '}
|
||||||
|
<code>onChangeEnd</code> pair — wire undo snapshots to Start and history commits
|
||||||
|
to End and the ledger always balances.
|
||||||
|
</p>
|
||||||
|
<table className="api-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>source</th>
|
||||||
|
<th>Emitted by</th>
|
||||||
|
<th>Start/End?</th>
|
||||||
|
<th>onChange cadence</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>'drag'</td><td>pointer drags (rotary, linear, track, XY, scrub)</td><td>yes — one pair per drag</td><td>each snapped value ('live') or once on release ('release')</td></tr>
|
||||||
|
<tr><td>'wheel'</td><td>scroll wheel / trackpad</td><td>yes — one pair per burst (150–250 ms settle)</td><td>each notch</td></tr>
|
||||||
|
<tr><td>'keyboard'</td><td>arrows, PageUp/Down, Home/End</td><td>yes — one pair per press</td><td>each step</td></tr>
|
||||||
|
<tr><td>'reset'</td><td>double-click reset</td><td>yes</td><td>once</td></tr>
|
||||||
|
<tr><td>'edit'</td><td>type-in editors (editable, ScrubField)</td><td>yes</td><td>once, on commit</td></tr>
|
||||||
|
<tr><td>'api'</td><td>setValue() from your code</td><td><strong>no</strong> — programmatic moves are not gestures</td><td>each call that lands on a new value</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="api-note">
|
||||||
|
A cancelled gesture (Esc, or <code>disabled</code> flipping mid-drag) restores the
|
||||||
|
start value and still fires <code>onChangeEnd</code>, so Start/End counts always
|
||||||
|
match. <code>onChange</code> never repeats a value within a gesture.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3 className="api-h">Prebuilt skins</h3>
|
<h3 className="api-h">Prebuilt skins</h3>
|
||||||
<Table>
|
<Table>
|
||||||
<Row name="<FlatKnob />" type="2D" desc="Modern arc knob. color, trackColor, faceColor, pointerColor, textColor, arcFrom ('min' | 'center'), arcThickness." />
|
<Row name="<FlatKnob />" type="2D" desc="Modern arc knob. color, trackColor, faceColor, pointerColor, textColor, arcFrom ('min' | 'center'), arcThickness." />
|
||||||
<Row name="<MetalKnob />" type="3D" desc="Brushed aluminum. tone ('silver' | 'dark'), color, tickCount, showArc, indicatorColor." />
|
<Row name="<MetalKnob />" type="3D" desc="Brushed aluminum. tone ('silver' | 'dark'), color, tickCount, showArc, indicatorColor." />
|
||||||
<Row name="<RubberKnob />" type="3D" desc="Soft-touch synth knob with glow halo. color, trackColor, arcFrom." />
|
<Row name="<RubberKnob />" type="3D" desc="Soft-touch synth knob with glow halo. color, trackColor, arcFrom." />
|
||||||
<Row name="<VintageKnob />" type="analog" desc="Chicken-head pointer. bodyColor (cream/bakelite/any), scaleColor, scaleLabels." />
|
<Row name="<VintageKnob />" type="analog" desc="Chicken-head pointer. bodyColor (cream/bakelite/any), scaleColor, scaleLabels." />
|
||||||
<Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals." />
|
<Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals (any precision), unit label under the digits." />
|
||||||
<Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." />
|
<Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." />
|
||||||
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
|
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
|
||||||
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." />
|
<Row name="<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="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
|
<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="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
|
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
|
||||||
<Row name="<Meter />" type="display" desc="Read-only LED level meter: value, zones, peakHold (ms), peakColor, orientation, seven-segment readout. Clip LED: showClip (default on), clipThreshold (default max), clipHold (ms or 'latch', click to clear), clipColor, onClip." />
|
<Row name="<Meter />" type="display" desc="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="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs, drag + arrow keys, grid, crosshair, Escape cancel, double-click reset." />
|
<Row name="<MeterBridge />" type="display" desc="Labeled multi-strip meter block: channels ([{label, value, clipValue?}]), per-channel peak-hold text, shared clip LED — the mixer meter row, prebuilt." />
|
||||||
<Row name="<PushButton />" type="switch" desc="Panel button with LED strip: toggle or momentary, controlled/uncontrolled, aria-pressed." />
|
<Row name="<XYPad />" type="2D control" desc="Two-parameter pad: x/y axis configs (each with min/max/step/invert/detents), drag + arrow keys (arrows follow the visual direction), grid, crosshair, Escape cancel, double-click reset." />
|
||||||
<Row name="<AlphaDisplay />" type="digital" desc="Fourteen-segment alphanumeric LED: A–Z 0–9 and symbols, chars padding, align, glow, skew." />
|
<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="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
|
<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="<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: A–Z 0–9 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." />
|
||||||
</Table>
|
</Table>
|
||||||
<p className="api-note">
|
<p className="api-note">
|
||||||
Every knob skin and fader also takes <code>label</code>, <code>showValue</code>,{' '}
|
Every knob skin and fader also takes <code>label</code>, <code>sublabel</code>{' '}
|
||||||
<code>unit</code>, <code>format</code>, <code>className</code>,{' '}
|
(a second caption line — <code>SEED</code> over <code>world #7</code>),{' '}
|
||||||
<code>style</code>, <code>name</code> plus every core prop above, and forwards a
|
<code>labelSize</code> (caption size independent of the dial diameter),{' '}
|
||||||
ref to its root element (<code>VintageKnob</code> has no readout, so it omits{' '}
|
<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
|
<code>showValue</code>/<code>unit</code>/<code>format</code>; the display
|
||||||
components <code>Meter</code>/<code>SegmentDisplay</code>/<code>AlphaDisplay</code>{' '}
|
components <code>Meter</code>/<code>SegmentDisplay</code>/<code>AlphaDisplay</code>{' '}
|
||||||
have their own smaller prop sets). Primitive-drawn parts carry{' '}
|
have their own smaller prop sets). Primitive-drawn parts carry{' '}
|
||||||
|
|
@ -118,13 +164,19 @@ export const ApiDocs: React.FC = () => (
|
||||||
<code>label</code>) and every interactive root exposes{' '}
|
<code>label</code>) and every interactive root exposes{' '}
|
||||||
<code>data-dragging</code>/<code>data-disabled</code>/<code>data-focus-visible</code>{' '}
|
<code>data-dragging</code>/<code>data-disabled</code>/<code>data-focus-visible</code>{' '}
|
||||||
— style any state or part with plain CSS. <code><Arc></code> additionally
|
— style any state or part with plain CSS. <code><Arc></code> additionally
|
||||||
accepts <code>gradient</code> (position-anchored color stops).
|
accepts <code>gradient</code> (position-anchored color stops). Geometry is
|
||||||
|
self-defending: <code>Meter</code>/<code>LEDFader</code> clamp their internal
|
||||||
|
padding below <code>breadth</code> 14 and never emit negative dimensions, so
|
||||||
|
4-px status slivers render fine.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="api-h">Theming</h3>
|
<h3 className="api-h">Theming</h3>
|
||||||
<p className="api-note">
|
<p className="api-note">
|
||||||
Every skin resolves its color and font defaults from the active theme; per-instance
|
Every skin resolves its color and font defaults from the active theme; per-instance
|
||||||
props always win. Use <code>base="light"</code> for light backgrounds.
|
props always win. Use <code>base="light"</code> for light backgrounds. Any CSS
|
||||||
|
color works — hex, <code>rgb()</code>, <code>oklch()</code>, even{' '}
|
||||||
|
<code>var(--accent)</code>: gradient and glow math uses hex fast-paths where it
|
||||||
|
can and falls back to CSS <code>color-mix()</code> everywhere else.
|
||||||
</p>
|
</p>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={`import { DreamknobProvider } from 'dreamknob'
|
code={`import { DreamknobProvider } from 'dreamknob'
|
||||||
|
|
@ -137,6 +189,13 @@ export const ApiDocs: React.FC = () => (
|
||||||
text: '#fff',
|
text: '#fff',
|
||||||
label: 'rgba(255,255,255,0.5)',
|
label: 'rgba(255,255,255,0.5)',
|
||||||
fontMono: '"IBM Plex Mono", monospace',
|
fontMono: '"IBM Plex Mono", monospace',
|
||||||
|
zoneGood: '#3df2ad', // meter zones: green / amber / red
|
||||||
|
zoneWarn: '#ffd23e', // used by Meter, MeterBridge, 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)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FlatKnob ... /> <Fader ... /> <LEDKnob ... />
|
<FlatKnob ... /> <Fader ... /> <LEDKnob ... />
|
||||||
|
|
@ -189,6 +248,92 @@ function MyKnob() {
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<h3 className="api-h">Small readouts</h3>
|
||||||
|
<p className="api-note">
|
||||||
|
Segment displays keep their proportions down to about{' '}
|
||||||
|
<strong>11 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.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`// Bipolar gain — arc grows from center, double-click back to 0
|
||||||
|
<FlatKnob min={-12} max={12} step={0.1} defaultValue={0}
|
||||||
|
origin={0} detents={[0]} unit=" dB" aria-label="Gain" />
|
||||||
|
|
||||||
|
// Angle dial — endless, wraps 0↔360, full-circle travel
|
||||||
|
<FlatKnob min={0} max={360} step={1} wrap
|
||||||
|
angleOffset={0} angleRange={360} unit="°" aria-label="Angle" />
|
||||||
|
|
||||||
|
// Expensive parameter — drag previews, engine hears one change on release
|
||||||
|
<MetalKnob min={0.1} max={10} step={0.1} commitMode="release"
|
||||||
|
onChange={v => rebuildReverbTail(v)} aria-label="Room size" />
|
||||||
|
|
||||||
|
// Wide-range filter cutoff — log taper puts the musical range under your thumb
|
||||||
|
<RubberKnob min={20} max={20000} taper={logTaper} step={1}
|
||||||
|
format={v => v >= 1000 ? (v / 1000).toFixed(1) + ' kHz' : v + ' Hz'}
|
||||||
|
aria-label="Cutoff" />
|
||||||
|
|
||||||
|
// Inspector knob — relative drag (no grab-jump), flick fast / drag slow
|
||||||
|
<FlatKnob size={40} rotaryMode="relative" dragAcceleration={0.5}
|
||||||
|
wheelRequiresFocus editable aria-label="Opacity" />
|
||||||
|
|
||||||
|
// One-line inspector row — fader with the readout beside it
|
||||||
|
<Fader orientation="horizontal" length={140} valuePosition="end"
|
||||||
|
editable unit="px" aria-label="Radius" />
|
||||||
|
|
||||||
|
// Stereo meter — one component, two channels, shared clip LED
|
||||||
|
<Meter value={[left, right]} min={-60} max={6} clipThreshold={0} />
|
||||||
|
|
||||||
|
// 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>`}
|
||||||
|
/>
|
||||||
|
|
||||||
<h3 className="api-h">Precision & number handling</h3>
|
<h3 className="api-h">Precision & number handling</h3>
|
||||||
<p className="api-note">
|
<p className="api-note">
|
||||||
Values are snapped to <code>step</code> (anchored at <code>min</code>) and rounded
|
Values are snapped to <code>step</code> (anchored at <code>min</code>) and rounded
|
||||||
|
|
|
||||||
157
apps/docs/src/sections/Buttons.tsx
Normal file
157
apps/docs/src/sections/Buttons.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
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 & switches</div>
|
||||||
|
<h2 className="section-title">Press, latch, or pick</h2>
|
||||||
|
<p className="section-sub">
|
||||||
|
Five components cover every panel control that isn'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><Button></code> is <code><PushButton mode="action"></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="dot"</code> for a corner dot that flashes on
|
||||||
|
press, or <code>led="strip"</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>
|
||||||
|
);
|
||||||
|
|
@ -2,23 +2,35 @@ import React from 'react';
|
||||||
import {
|
import {
|
||||||
AlphaDisplay,
|
AlphaDisplay,
|
||||||
Arc,
|
Arc,
|
||||||
|
Button,
|
||||||
DreamknobProvider,
|
DreamknobProvider,
|
||||||
Fader,
|
Fader,
|
||||||
FlatKnob,
|
FlatKnob,
|
||||||
|
Gauge,
|
||||||
ImageKnob,
|
ImageKnob,
|
||||||
Knob,
|
Knob,
|
||||||
KnobValue,
|
KnobValue,
|
||||||
|
IndicatorLamp,
|
||||||
|
LabeledField,
|
||||||
|
LampRow,
|
||||||
LEDFader,
|
LEDFader,
|
||||||
LEDKnob,
|
LEDKnob,
|
||||||
MetalKnob,
|
MetalKnob,
|
||||||
Meter,
|
Meter,
|
||||||
|
MeterBridge,
|
||||||
NeonKnob,
|
NeonKnob,
|
||||||
|
PanKnob,
|
||||||
Pointer,
|
Pointer,
|
||||||
PushButton,
|
PushButton,
|
||||||
|
Rack,
|
||||||
RubberKnob,
|
RubberKnob,
|
||||||
|
ScrubField,
|
||||||
|
SegmentSwitch,
|
||||||
SegmentDisplay,
|
SegmentDisplay,
|
||||||
SteppedKnob,
|
SteppedKnob,
|
||||||
Ticks,
|
Ticks,
|
||||||
|
ToggleSwitch,
|
||||||
|
TransportButton,
|
||||||
VintageKnob,
|
VintageKnob,
|
||||||
XYPad,
|
XYPad,
|
||||||
logTaper,
|
logTaper,
|
||||||
|
|
@ -117,19 +129,265 @@ const MeterDemo: React.FC = () => {
|
||||||
}, 90);
|
}, 90);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
const zones = [
|
return (
|
||||||
{ upTo: 0.72, color: '#3df2ad' },
|
<div style={{ display: 'flex', gap: 22, alignItems: 'flex-end' }}>
|
||||||
|
<Meter
|
||||||
|
value={levels}
|
||||||
|
min={-60}
|
||||||
|
max={6}
|
||||||
|
length={132}
|
||||||
|
peakColor="#fff"
|
||||||
|
label="L / R"
|
||||||
|
aria-label="Stereo level"
|
||||||
|
/>
|
||||||
|
<MeterBridge
|
||||||
|
channels={[
|
||||||
|
{ label: 'L', value: levels[0] },
|
||||||
|
{ label: 'R', value: levels[1] },
|
||||||
|
{ label: 'M', value: (levels[0] + levels[1]) / 2 },
|
||||||
|
{ label: 'S', value: (levels[0] - levels[1]) / 2 - 20 },
|
||||||
|
]}
|
||||||
|
min={-60}
|
||||||
|
max={6}
|
||||||
|
length={132}
|
||||||
|
breadth={20}
|
||||||
|
peakTextDecimals={0}
|
||||||
|
label="Bridge"
|
||||||
|
aria-label="Meter bridge"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LampSwitchDemo: React.FC = () => {
|
||||||
|
const [mode, setMode] = React.useState(1);
|
||||||
|
const [armed, setArmed] = React.useState(true);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||||
|
<SegmentSwitch
|
||||||
|
options={['LP', 'BP', 'HP', 'NOTCH']}
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
color="#4cc2ff"
|
||||||
|
aria-label="Filter type"
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
|
||||||
|
<IndicatorLamp on={armed} onChange={setArmed} color="#ff4d6b" label="REC" aria-label="Record arm" />
|
||||||
|
<IndicatorLamp on={armed} readOnly blink color="#ff4d6b" label="SIG" aria-label="Signal lamp" />
|
||||||
|
<PushButton led="dot" size={30} color="#3df2ad" defaultPressed aria-label="Snap toggle">
|
||||||
|
⌗
|
||||||
|
</PushButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommitModeDemo: React.FC = () => {
|
||||||
|
const [commits, setCommits] = React.useState(0);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 22, alignItems: 'center' }}>
|
||||||
|
<FlatKnob
|
||||||
|
size={84}
|
||||||
|
defaultValue={35}
|
||||||
|
color="#ffd23e"
|
||||||
|
label="Release"
|
||||||
|
commitMode="release"
|
||||||
|
onChange={() => setCommits(n => n + 1)}
|
||||||
|
aria-label="Release commit demo"
|
||||||
|
/>
|
||||||
|
<div data-commit-count={commits} style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||||
|
<SegmentDisplay value={commits} digits={3} decimals={0} height={20} color="#ffd23e" />
|
||||||
|
<span style={{ fontSize: 10, letterSpacing: '0.08em', color: 'rgba(232,234,240,0.45)', textTransform: 'uppercase' }}>
|
||||||
|
onChange calls
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESOURCE_ZONES = [
|
||||||
|
{ upTo: 0.75, color: '#3df2ad' },
|
||||||
{ upTo: 0.9, color: '#ffd23e' },
|
{ upTo: 0.9, color: '#ffd23e' },
|
||||||
{ upTo: 1, color: '#ff4d6b' },
|
{ 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 (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
|
||||||
<Meter value={levels[0]} min={-60} max={6} length={132} zones={zones} peakColor="#fff" label="L" aria-label="Left level" />
|
<Gauge value={load[0]} min={0} max={100} size={104} unit="%" label="CPU" zones={RESOURCE_ZONES} />
|
||||||
<Meter value={levels[1]} min={-60} max={6} length={132} zones={zones} peakColor="#fff" label="R" aria-label="Right level" />
|
<Gauge value={load[1]} min={0} max={100} size={104} unit="%" label="GPU" zones={RESOURCE_ZONES} needle={false} />
|
||||||
</div>
|
</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 ImageKnobDemo: React.FC = () => {
|
||||||
const src = useGeneratedStrip();
|
const src = useGeneratedStrip();
|
||||||
if (!src) return null;
|
if (!src) return null;
|
||||||
|
|
@ -343,10 +601,49 @@ export const Gallery: React.FC = () => (
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Meter" desc="Read-only LED metering with peak hold and a latching clip LED — click it to clear." tag="<Meter clipHold={1500} />">
|
<Card title="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 />
|
<MeterDemo />
|
||||||
</Card>
|
</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>
|
||||||
|
|
||||||
|
<Card title="Scrub field" desc="After-Effects-style numbers: drag the label to scrub, click to type, Shift for fine. '1.2k' parses too." tag="<ScrubField />">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<ScrubField label="Width" defaultValue={1280} min={0} max={4096} unit="px" aria-label="Width" />
|
||||||
|
<ScrubField label="Scale" defaultValue={1} min={0} max={10} step={0.01} widthCh={5} unit="×" aria-label="Scale" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Release commit" desc="commitMode='release' previews the drag but your app hears one onChange, on pointer-up — for parameters that are expensive to apply." tag="commitMode='release'">
|
||||||
|
<CommitModeDemo />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="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={...} />">
|
<Card title="Gradient arc" desc="Position-anchored gradient — the red zone stays at the top of travel." tag="<Arc gradient={...} />">
|
||||||
<Knob size={96} min={-60} max={6} step={0.5} defaultValue={-8} aria-label="Gradient arc demo">
|
<Knob size={96} min={-60} max={6} step={0.5} defaultValue={-8} aria-label="Gradient arc demo">
|
||||||
<Arc
|
<Arc
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,12 @@ export const GettingStarted: React.FC = () => (
|
||||||
|
|
||||||
<Step n={1} title="Install">
|
<Step n={1} title="Install">
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz\n# (or npm install <same url>) · peer deps: react >= 18, react-dom >= 18`}
|
code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz\n# (or npm install <same url>) · peer deps: react >= 18, react-dom >= 18`}
|
||||||
/>
|
/>
|
||||||
<p className="api-note" style={{ marginTop: 10 }}>
|
<p className="api-note" style={{ marginTop: 10 }}>
|
||||||
Installs straight from the{' '}
|
Installs straight from the{' '}
|
||||||
<a href="https://git.dreamodus.software/Dreamodus/DreamKnob/releases">
|
<a href="https://git.dreamodus.software/Dreamodus/DreamKnob/releases">
|
||||||
v1.0.0 release
|
v1.1.4 release
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No
|
on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No
|
||||||
CSS file to import — everything is SVG and inline styles.
|
CSS file to import — everything is SVG and inline styles.
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@ export const Hero: React.FC = () => {
|
||||||
range, any decimal precision, and colors you fully control.
|
range, any decimal precision, and colors you fully control.
|
||||||
</p>
|
</p>
|
||||||
<div className="install">
|
<div className="install">
|
||||||
<span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz</span>
|
<span>pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz');
|
navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz');
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1400);
|
setTimeout(() => setCopied(false), 1400);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,76 @@ h2.section-title {
|
||||||
text-transform: uppercase;
|
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 panel ---- */
|
||||||
.synth-rows {
|
.synth-rows {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,183 @@
|
||||||
# Changelog
|
# 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
|
||||||
|
real product.
|
||||||
|
|
||||||
|
### New components
|
||||||
|
- `IndicatorLamp` — pressable LED lamp (`role="switch"`), toggle/momentary, `blink`
|
||||||
|
(reduced-motion aware), `readOnly` status-light mode
|
||||||
|
- `SegmentSwitch` — hardware-styled segmented selector (radiogroup, roving focus,
|
||||||
|
arrow/Home/End keys, LED tick)
|
||||||
|
- `ScrubField` — After-Effects-style numeric field: label scrubbing (Shift = fine),
|
||||||
|
draft typing (Enter commits, Esc reverts), arrow steps, wheel; `role="spinbutton"`
|
||||||
|
- `MeterBridge` — labeled multi-strip meter block with per-channel peak-hold text
|
||||||
|
- `PushButton` gains `led="dot"` — corner-dot LED that scales down for small
|
||||||
|
icon-only toggles (`led` now `boolean | 'strip' | 'dot'`)
|
||||||
|
|
||||||
|
### New capabilities
|
||||||
|
- `commitMode="release"` — drags preview visually but emit a single `onChange` on
|
||||||
|
pointer-up; Esc discards the preview (expensive parameters)
|
||||||
|
- `Meter` accepts `value={[l, r, ...]}` for multi-channel bars with a shared clip LED
|
||||||
|
- Theme gains `zoneGood` / `zoneWarn` / `zoneHot`; `Meter`, `MeterBridge` and
|
||||||
|
`LEDFader` default their zones from the theme
|
||||||
|
- `XYPad` axes accept `invert` and per-axis `detents`; arrow keys follow the visual
|
||||||
|
direction on inverted axes
|
||||||
|
- `Fader` gains `valuePosition: 'top' | 'end' | 'none'` — `'end'` renders one-line
|
||||||
|
inspector rows with a stable readout width
|
||||||
|
- `LEDKnob` / `LEDFader` / `Meter` gain `unit`; `displayDecimals` is no longer capped
|
||||||
|
- Skins without visible readouts now default `aria-valuetext` from `format`/`unit`
|
||||||
|
(`SteppedKnob` announces position names)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `Meter` clamps geometry below `breadth` 14 — no negative-dimension SVG rects on
|
||||||
|
thin strips
|
||||||
|
- `mixColors` falls back to CSS `color-mix()` for non-hex inputs, so theme tokens can
|
||||||
|
be `var(--x)` / `oklch()` without breaking gradients and glows
|
||||||
|
- Type-in editing now fires a proper edit gesture: `onChangeStart` → `onChange` →
|
||||||
|
`onChangeEnd` with `source: 'edit'` (was a bare `'api'` set with no gesture events)
|
||||||
|
- The `ChangeMeta.source` contract is documented (docs + `types.ts`): every user
|
||||||
|
gesture brackets exactly one Start/End pair; `'api'` fires neither
|
||||||
|
- Disabled controls set `data-disabled` on the wrapper (including labels/readouts)
|
||||||
|
and dim uniformly
|
||||||
|
|
||||||
## 1.0.0 — 2026-07-12
|
## 1.0.0 — 2026-07-12
|
||||||
|
|
||||||
Initial release.
|
Initial release.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ headless core, any number range, any decimal precision, and colors you fully con
|
||||||
Zero runtime dependencies.
|
Zero runtime dependencies.
|
||||||
|
|
||||||
```
|
```
|
||||||
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz
|
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.4/dreamknob-1.1.4.tgz
|
||||||
```
|
```
|
||||||
|
|
||||||
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
|
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
|
||||||
|
|
@ -73,7 +73,7 @@ when you provide `getAriaValueText`).
|
||||||
the cursor mid-gesture.
|
the cursor mid-gesture.
|
||||||
- `wheelStep` / `wheelRequiresFocus` — tune wheel notches, or keep hover-scroll free.
|
- `wheelStep` / `wheelRequiresFocus` — tune wheel notches, or keep hover-scroll free.
|
||||||
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with
|
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks with
|
||||||
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'`).
|
`meta.source` (`'drag' | 'wheel' | 'keyboard' | 'reset' | 'edit' | 'api'`).
|
||||||
- `wrap` — endless encoder mode: values roll around min↔max (pair with
|
- `wrap` — endless encoder mode: values roll around min↔max (pair with
|
||||||
`angleOffset={0} angleRange={360}`).
|
`angleOffset={0} angleRange={360}`).
|
||||||
- `animateChanges` — tween the pointer on programmatic changes (preset loads);
|
- `animateChanges` — tween the pointer on programmatic changes (preset loads);
|
||||||
|
|
@ -95,17 +95,36 @@ import {
|
||||||
LEDKnob, // segmented LED ring + true seven-segment readout
|
LEDKnob, // segmented LED ring + true seven-segment readout
|
||||||
NeonKnob, // glowing arc for dark UIs
|
NeonKnob, // glowing arc for dark UIs
|
||||||
SteppedKnob, // detented selector (positions={['LP','BP','HP']})
|
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
|
Fader, // linear channel fader, vertical or horizontal
|
||||||
LEDFader, // segmented LED meter-fader with color zones
|
LEDFader, // segmented LED meter-fader with color zones
|
||||||
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
|
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
|
||||||
Meter, // read-only LED level meter with peak hold + latching clip LED
|
Meter, // LED level meter — multi-channel, PPM ballistics, dBFS scale, loudness
|
||||||
|
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)
|
XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
|
||||||
PushButton, // panel button with LED strip (toggle or momentary)
|
PushButton, // latch/hold panel button — LED strip or corner dot (toggle/momentary)
|
||||||
SegmentDisplay, // standalone seven-segment numeric display
|
Button, // click-to-fire command button (no LED by default; led="dot" opt-in)
|
||||||
AlphaDisplay, // fourteen-segment alphanumeric display
|
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)
|
||||||
} from 'dreamknob'
|
} 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
|
## Theming
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|
@ -117,7 +136,10 @@ import { DreamknobProvider } from 'dreamknob'
|
||||||
```
|
```
|
||||||
|
|
||||||
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
|
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
|
||||||
`text`, `label`, `ticks`, `focusRing`, `fontMono`, `fontUI`.
|
`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.
|
||||||
|
|
||||||
Every knob skin and fader takes the core props plus `size`, `label`, `showValue`,
|
Every knob skin and fader takes the core props plus `size`, `label`, `showValue`,
|
||||||
`unit`, `format`, and per-part color props (`color`, `trackColor`, `faceColor`,
|
`unit`, `format`, and per-part color props (`color`, `trackColor`, `faceColor`,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dreamknob",
|
"name": "dreamknob",
|
||||||
"version": "1.0.0",
|
"version": "1.1.4",
|
||||||
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
|
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Dreamodus Software Inc.",
|
"author": "Dreamodus Software Inc.",
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(function Knob(
|
||||||
initial={String(knob.value)}
|
initial={String(knob.value)}
|
||||||
onCommit={raw => {
|
onCommit={raw => {
|
||||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||||
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
|
||||||
closeEditor();
|
closeEditor();
|
||||||
}}
|
}}
|
||||||
onCancel={closeEditor}
|
onCancel={closeEditor}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,13 @@ describe('mixColors', () => {
|
||||||
it('expands shorthand hex', () => {
|
it('expands shorthand hex', () => {
|
||||||
expect(mixColors('#f00', '#f00', 0.5)).toBe('#ff0000');
|
expect(mixColors('#f00', '#f00', 0.5)).toBe('#ff0000');
|
||||||
});
|
});
|
||||||
it('falls back to the nearer endpoint for non-hex input', () => {
|
it('produces color-mix() for non-hex input (CSS vars, named colors)', () => {
|
||||||
expect(mixColors('red', '#00ff00', 0.2)).toBe('red');
|
expect(mixColors('var(--accent)', '#00ff00', 0.25)).toBe(
|
||||||
expect(mixColors('red', '#00ff00', 0.8)).toBe('#00ff00');
|
'color-mix(in srgb, #00ff00 25%, var(--accent))',
|
||||||
|
);
|
||||||
|
expect(mixColors('red', 'rgb(0, 255, 0)', 0.5)).toBe(
|
||||||
|
'color-mix(in srgb, rgb(0, 255, 0) 50%, red)',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,19 @@ const parseHex = (color: string): [number, number, number, number] | null => {
|
||||||
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
|
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mix two hex colors. Non-hex inputs fall back to the nearer endpoint. */
|
/**
|
||||||
|
* Mix two colors. Hex inputs are interpolated numerically; anything else
|
||||||
|
* (var(--x), rgb(), named colors, color-mix() …) falls back to a CSS
|
||||||
|
* `color-mix()` expression, which SVG fills/strokes accept in modern
|
||||||
|
* browsers — so theme tokens may be any resolvable CSS color.
|
||||||
|
*/
|
||||||
export const mixColors = (a: string, b: string, t: number): string => {
|
export const mixColors = (a: string, b: string, t: number): string => {
|
||||||
const ca = parseHex(a);
|
const ca = parseHex(a);
|
||||||
const cb = parseHex(b);
|
const cb = parseHex(b);
|
||||||
if (!ca || !cb) return t < 0.5 ? a : b;
|
if (!ca || !cb) {
|
||||||
|
const pct = Math.round(clamp01(t) * 1000) / 10;
|
||||||
|
return `color-mix(in srgb, ${b} ${pct}%, ${a})`;
|
||||||
|
}
|
||||||
const lerp = (x: number, y: number) => Math.round(x + (y - x) * t);
|
const lerp = (x: number, y: number) => Math.round(x + (y - x) * t);
|
||||||
const [r, g, bl, al] = [0, 1, 2, 3].map(i => lerp(ca[i], cb[i]));
|
const [r, g, bl, al] = [0, 1, 2, 3].map(i => lerp(ca[i], cb[i]));
|
||||||
const hex = (v: number) => v.toString(16).padStart(2, '0');
|
const hex = (v: number) => v.toString(16).padStart(2, '0');
|
||||||
|
|
@ -26,6 +34,8 @@ export const mixColors = (a: string, b: string, t: number): string => {
|
||||||
: `#${hex(r)}${hex(g)}${hex(bl)}${hex(al)}`;
|
: `#${hex(r)}${hex(g)}${hex(bl)}${hex(al)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clamp01 = (t: number) => Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sample a gradient at normalized position `t`. Stops are sorted by `at`;
|
* Sample a gradient at normalized position `t`. Stops are sorted by `at`;
|
||||||
* positions outside the stops clamp to the nearest end.
|
* positions outside the stops clamp to the nearest end.
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,70 @@ export interface KnobTheme {
|
||||||
ticks: string;
|
ticks: string;
|
||||||
/** Keyboard focus ring. */
|
/** Keyboard focus ring. */
|
||||||
focusRing: string;
|
focusRing: string;
|
||||||
|
/** Meter/LED zone semantics: healthy level. */
|
||||||
|
zoneGood: string;
|
||||||
|
/** Meter/LED zone semantics: getting hot. */
|
||||||
|
zoneWarn: string;
|
||||||
|
/** Meter/LED zone semantics: too hot / clipping. */
|
||||||
|
zoneHot: string;
|
||||||
|
/** 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;
|
fontMono: string;
|
||||||
fontUI: 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 = {
|
export const darkTheme: KnobTheme = {
|
||||||
track: 'rgba(255,255,255,0.12)',
|
track: 'rgba(255,255,255,0.12)',
|
||||||
face: 'rgba(255,255,255,0.05)',
|
face: 'rgba(255,255,255,0.05)',
|
||||||
|
|
@ -39,6 +99,14 @@ export const darkTheme: KnobTheme = {
|
||||||
label: 'rgba(255,255,255,0.45)',
|
label: 'rgba(255,255,255,0.45)',
|
||||||
ticks: 'rgba(255,255,255,0.18)',
|
ticks: 'rgba(255,255,255,0.18)',
|
||||||
focusRing: 'rgba(130,180,255,0.65)',
|
focusRing: 'rgba(130,180,255,0.65)',
|
||||||
|
zoneGood: '#3df2ad',
|
||||||
|
zoneWarn: '#ffd23e',
|
||||||
|
zoneHot: '#ff4d6b',
|
||||||
|
ledGreen: '#3df2ad',
|
||||||
|
ledAmber: '#ffb84d',
|
||||||
|
panel: 'rgba(255,255,255,0.03)',
|
||||||
|
ledWell: '#0b0d0e',
|
||||||
|
scheme: 'dark',
|
||||||
fontMono: MONO_FONT,
|
fontMono: MONO_FONT,
|
||||||
fontUI: UI_FONT,
|
fontUI: UI_FONT,
|
||||||
};
|
};
|
||||||
|
|
@ -50,6 +118,14 @@ export const lightTheme: KnobTheme = {
|
||||||
label: 'rgba(0,0,0,0.5)',
|
label: 'rgba(0,0,0,0.5)',
|
||||||
ticks: 'rgba(0,0,0,0.28)',
|
ticks: 'rgba(0,0,0,0.28)',
|
||||||
focusRing: 'rgba(0,110,220,0.55)',
|
focusRing: 'rgba(0,110,220,0.55)',
|
||||||
|
zoneGood: '#0fa571',
|
||||||
|
zoneWarn: '#c99000',
|
||||||
|
zoneHot: '#d92546',
|
||||||
|
ledGreen: '#0fa571',
|
||||||
|
ledAmber: '#b26a00',
|
||||||
|
panel: 'rgba(0,0,0,0.04)',
|
||||||
|
ledWell: 'transparent',
|
||||||
|
scheme: 'light',
|
||||||
fontMono: MONO_FONT,
|
fontMono: MONO_FONT,
|
||||||
fontUI: UI_FONT,
|
fontUI: UI_FONT,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
import type { Taper } from './math';
|
import type { Taper } from './math';
|
||||||
|
|
||||||
/** What produced a value change. */
|
/**
|
||||||
export type ChangeSource = 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api';
|
* What produced a value change.
|
||||||
|
*
|
||||||
|
* Gesture contract — which sources bracket changes with onChangeStart/End:
|
||||||
|
* | source | onChangeStart | onChange (per step) | onChangeEnd |
|
||||||
|
* | 'drag' | yes | yes | yes (pointer up / Escape / disabled) |
|
||||||
|
* | 'wheel' | yes (burst) | yes | yes (250 ms settle) |
|
||||||
|
* | 'keyboard' | yes (burst) | yes | yes (key up / blur) |
|
||||||
|
* | 'reset' | yes | yes | yes |
|
||||||
|
* | 'edit' | yes | yes | yes (type-in editor commit) |
|
||||||
|
* | 'api' | no | yes | no (imperative setValue) |
|
||||||
|
*/
|
||||||
|
export type ChangeSource = 'drag' | 'wheel' | 'keyboard' | 'reset' | 'edit' | 'api';
|
||||||
|
|
||||||
export interface ChangeMeta {
|
export interface ChangeMeta {
|
||||||
source: ChangeSource;
|
source: ChangeSource;
|
||||||
|
|
@ -80,6 +91,14 @@ export interface KnobCoreProps {
|
||||||
* `prefers-reduced-motion` disables it. Default: false.
|
* `prefers-reduced-motion` disables it. Default: false.
|
||||||
*/
|
*/
|
||||||
animateChanges?: boolean | { duration?: number };
|
animateChanges?: boolean | { duration?: number };
|
||||||
|
/**
|
||||||
|
* When to emit onChange for pointer drags. 'live' (default) fires per
|
||||||
|
* movement; 'release' previews the drag visually but fires onChange exactly
|
||||||
|
* once when the pointer is released — for network-expensive or
|
||||||
|
* side-effectful parameters. Wheel, keyboard and type-in stay immediate
|
||||||
|
* (deliberate acts), and Escape discards the preview without committing.
|
||||||
|
*/
|
||||||
|
commitMode?: 'live' | 'release';
|
||||||
/** Drag behaviour. Default: 'rotary'. */
|
/** Drag behaviour. Default: 'rotary'. */
|
||||||
interaction?: InteractionMode;
|
interaction?: InteractionMode;
|
||||||
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */
|
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
// Fourteen-segment geometry in a local 10 x 18 digit cell.
|
// 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,
|
// Outer ring: a top, b top-right, c bottom-right, d bottom, e bottom-left,
|
||||||
|
|
@ -7,38 +8,38 @@ import * as React from 'react';
|
||||||
|
|
||||||
const W = 10;
|
const W = 10;
|
||||||
const H = 18;
|
const H = 18;
|
||||||
const GAP = 3.4;
|
const DEFAULT_GAP = 3.4;
|
||||||
const HALF = 1.05;
|
const BASE_HALF = 1.05;
|
||||||
const INSET = 0.45;
|
const INSET = 0.45;
|
||||||
|
|
||||||
const hSeg = (y: number, x1: number, x2: number): string => {
|
const hSeg = (y: number, x1: number, x2: number, half: number): string => {
|
||||||
const a = x1 + INSET;
|
const a = x1 + INSET;
|
||||||
const b = x2 - INSET;
|
const b = x2 - INSET;
|
||||||
return `${a},${y} ${a + HALF},${y - HALF} ${b - HALF},${y - HALF} ${b},${y} ${b - HALF},${y + HALF} ${a + HALF},${y + HALF}`;
|
return `${a},${y} ${a + half},${y - half} ${b - half},${y - half} ${b},${y} ${b - half},${y + half} ${a + half},${y + half}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const vSeg = (x: number, y1: number, y2: number): string => {
|
const vSeg = (x: number, y1: number, y2: number, half: number): string => {
|
||||||
const a = y1 + INSET;
|
const a = y1 + INSET;
|
||||||
const b = y2 - 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 =
|
type Seg14 =
|
||||||
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
|
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
|
||||||
| 'g1' | 'g2' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm';
|
| 'g1' | 'g2' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm';
|
||||||
|
|
||||||
const POLY: Partial<Record<Seg14, string>> = {
|
const buildPoly = (half: number): Partial<Record<Seg14, string>> => ({
|
||||||
a: hSeg(1, 1, 9),
|
a: hSeg(1, 1, 9, half),
|
||||||
d: hSeg(17, 1, 9),
|
d: hSeg(17, 1, 9, half),
|
||||||
g1: hSeg(9, 1, 5),
|
g1: hSeg(9, 1, 5, half),
|
||||||
g2: hSeg(9, 5, 9),
|
g2: hSeg(9, 5, 9, half),
|
||||||
f: vSeg(1, 1, 9),
|
f: vSeg(1, 1, 9, half),
|
||||||
b: vSeg(9, 1, 9),
|
b: vSeg(9, 1, 9, half),
|
||||||
e: vSeg(1, 9, 17),
|
e: vSeg(1, 9, 17, half),
|
||||||
c: vSeg(9, 9, 17),
|
c: vSeg(9, 9, 17, half),
|
||||||
i: vSeg(5, 1, 9),
|
i: vSeg(5, 1, 9, half),
|
||||||
l: vSeg(5, 9, 17),
|
l: vSeg(5, 9, 17, half),
|
||||||
};
|
});
|
||||||
|
|
||||||
/** Diagonals render as thick lines: [x1, y1, x2, y2]. */
|
/** Diagonals render as thick lines: [x1, y1, x2, y2]. */
|
||||||
const DIAG: Partial<Record<Seg14, [number, number, number, number]>> = {
|
const DIAG: Partial<Record<Seg14, [number, number, number, number]>> = {
|
||||||
|
|
@ -85,6 +86,8 @@ const CHARS: Record<string, Seg14[]> = {
|
||||||
X: ['h', 'j', 'k', 'm'],
|
X: ['h', 'j', 'k', 'm'],
|
||||||
Y: ['h', 'j', 'l'],
|
Y: ['h', 'j', 'l'],
|
||||||
Z: ['a', 'd', 'j', 'k'],
|
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'],
|
'-': ['g1', 'g2'],
|
||||||
'_': ['d'],
|
'_': ['d'],
|
||||||
'=': ['g1', 'g2', 'd'],
|
'=': ['g1', 'g2', 'd'],
|
||||||
|
|
@ -96,17 +99,25 @@ const CHARS: Record<string, Seg14[]> = {
|
||||||
' ': [],
|
' ': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_SEGMENTS = Object.keys({ ...POLY, ...DIAG }) as Seg14[];
|
const ALL_SEGMENTS = Object.keys({ ...buildPoly(BASE_HALF), ...DIAG }) as Seg14[];
|
||||||
|
|
||||||
export interface AlphaDisplayProps {
|
export interface AlphaDisplayProps {
|
||||||
/** Text: A–Z, 0–9, space, - _ = + * / \ ? and '.' (attaches to the previous cell). */
|
/** Text: A–Z, 0–9, space, - _ = + * / \ ? $ : % and '.' (attaches to the previous cell). */
|
||||||
value: string;
|
value: string;
|
||||||
/** Fixed number of character cells; the text is padded/cropped to fit. */
|
/** Fixed number of character cells; the text is padded/cropped to fit. */
|
||||||
chars?: number;
|
chars?: number;
|
||||||
align?: 'left' | 'right';
|
align?: 'left' | 'right';
|
||||||
/** Character height in px. Default: 22. */
|
/** Character height in px. Default: 22. */
|
||||||
height?: number;
|
height?: number;
|
||||||
|
/** Lit color. Defaults to the theme's `ledAmber`. */
|
||||||
color?: string;
|
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. */
|
/** Panel background. Set to 'none' to disable. */
|
||||||
background?: string;
|
background?: string;
|
||||||
/** Opacity of unlit segments. Default: 0.08. */
|
/** Opacity of unlit segments. Default: 0.08. */
|
||||||
|
|
@ -116,6 +127,8 @@ export interface AlphaDisplayProps {
|
||||||
/** LED glow strength (0 disables). Default: 1.4. */
|
/** LED glow strength (0 disables). Default: 1.4. */
|
||||||
glow?: number;
|
glow?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
|
/** Dim the display uniformly (matches disabled controls). */
|
||||||
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
@ -129,20 +142,32 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
||||||
chars,
|
chars,
|
||||||
align = 'left',
|
align = 'left',
|
||||||
height = 22,
|
height = 22,
|
||||||
color = '#ffb84d',
|
color,
|
||||||
background = '#0a0d0c',
|
weight = 1,
|
||||||
|
gap = DEFAULT_GAP,
|
||||||
|
background: backgroundProp,
|
||||||
ghostOpacity = 0.08,
|
ghostOpacity = 0.08,
|
||||||
skew = 6,
|
skew = 6,
|
||||||
glow = 1.4,
|
glow = 1.4,
|
||||||
padding = 6,
|
padding = 6,
|
||||||
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
style,
|
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 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();
|
let text = value.toUpperCase();
|
||||||
if (chars !== undefined) {
|
if (chars !== undefined) {
|
||||||
const cellCount = text.replace(/\./g, '').length;
|
const cellCount = text.replace(/[.]/g, '').length;
|
||||||
if (cellCount < chars) {
|
if (cellCount < chars) {
|
||||||
const pad = ' '.repeat(chars - cellCount);
|
const pad = ' '.repeat(chars - cellCount);
|
||||||
text = align === 'right' ? pad + text : text + pad;
|
text = align === 'right' ? pad + text : text + pad;
|
||||||
|
|
@ -164,19 +189,43 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
||||||
for (const ch of text) {
|
for (const ch of text) {
|
||||||
if (ch === '.') {
|
if (ch === '.') {
|
||||||
cells.push(
|
cells.push(
|
||||||
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={H - 1.2} r={1.15} fill={color} />,
|
<rect key={key++} x={x - dot} y={H - dot * 2} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />,
|
||||||
);
|
);
|
||||||
|
x += dot * 2 + Math.min(gap * 0.6, 2.6);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lit = new Set(CHARS[ch] ?? []);
|
if (ch === ':') {
|
||||||
|
const cx = x + dot;
|
||||||
|
cells.push(
|
||||||
|
<g key={key++}>
|
||||||
|
<rect x={cx - dot} y={H * 0.3 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />
|
||||||
|
<rect x={cx - dot} y={H * 0.72 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
x += dot * 2 + gap;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '%') {
|
||||||
|
// Slash with a dot at each end — the percent idiom.
|
||||||
|
cells.push(
|
||||||
|
<g key={key++} transform={`translate(${x} 0)`}>
|
||||||
|
<line x1={2} y1={16} x2={8} y2={2} stroke={lit} strokeWidth={diagWidth} strokeLinecap="butt" />
|
||||||
|
<circle cx={2.8} cy={4} r={dot * 1.05} fill={lit} />
|
||||||
|
<circle cx={7.2} cy={14} r={dot * 1.05} fill={lit} />
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
x += W + gap;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const on = new Set(CHARS[ch] ?? []);
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: React.ReactNode[] = [];
|
||||||
for (const seg of ALL_SEGMENTS) {
|
for (const seg of ALL_SEGMENTS) {
|
||||||
const on = lit.has(seg);
|
const isOn = on.has(seg);
|
||||||
if (!on && ghostOpacity <= 0) continue;
|
if (!isOn && ghostOpacity <= 0) continue;
|
||||||
const opacity = on ? 1 : ghostOpacity;
|
const opacity = isOn ? 1 : ghostOpacity;
|
||||||
const poly = POLY[seg];
|
const poly = POLY[seg];
|
||||||
if (poly) {
|
if (poly) {
|
||||||
parts.push(<polygon key={seg} points={poly} fill={color} opacity={opacity} />);
|
parts.push(<polygon key={seg} points={poly} fill={lit} opacity={opacity} />);
|
||||||
} else {
|
} else {
|
||||||
const [x1, y1, x2, y2] = DIAG[seg]!;
|
const [x1, y1, x2, y2] = DIAG[seg]!;
|
||||||
parts.push(
|
parts.push(
|
||||||
|
|
@ -186,8 +235,8 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
||||||
y1={y1}
|
y1={y1}
|
||||||
x2={x2}
|
x2={x2}
|
||||||
y2={y2}
|
y2={y2}
|
||||||
stroke={color}
|
stroke={lit}
|
||||||
strokeWidth={1.5}
|
strokeWidth={diagWidth}
|
||||||
strokeLinecap="butt"
|
strokeLinecap="butt"
|
||||||
opacity={opacity}
|
opacity={opacity}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -199,10 +248,10 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
||||||
{parts}
|
{parts}
|
||||||
</g>,
|
</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 scale = height / H;
|
||||||
const w = contentW * scale + padding * 2 + height * 0.15;
|
const w = contentW * scale + padding * 2 + height * 0.15;
|
||||||
const h = height + padding * 2;
|
const h = height + padding * 2;
|
||||||
|
|
@ -213,7 +262,8 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
||||||
height={h}
|
height={h}
|
||||||
viewBox={`0 0 ${w} ${h}`}
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={value}
|
aria-label={value}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,33 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { formatForDisplay, renderSegmentText } from './segments';
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import { DisplayZone, formatForDisplay, renderSegmentText, zoneColorForValue } from './segments';
|
||||||
|
|
||||||
export interface SegmentDisplayProps {
|
export interface SegmentDisplayProps {
|
||||||
value: number | string;
|
value: number | string;
|
||||||
/** Number of digit cells (decimal points don't count). Default: 4. */
|
/** Number of digit cells (points/colons don't count). Default: 4. */
|
||||||
digits?: number;
|
digits?: number;
|
||||||
decimals?: number;
|
decimals?: number;
|
||||||
/** Digit height in px. Default: 28. */
|
/** Digit height in px. Default: 28. Below ~11px, bump `weight` to stay legible. */
|
||||||
height?: number;
|
height?: number;
|
||||||
|
/** Lit color. Defaults to the theme's `ledGreen`. `valueColor`/`zones` win over it. */
|
||||||
color?: string;
|
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. */
|
/** Panel background. Set to 'none' to disable. */
|
||||||
background?: string;
|
background?: string;
|
||||||
/** Opacity of unlit segments. Default: 0.09. */
|
/** Opacity of unlit segments. Default: 0.09. */
|
||||||
|
|
@ -18,6 +37,8 @@ export interface SegmentDisplayProps {
|
||||||
/** LED glow strength (0 disables). Default: 1.6. */
|
/** LED glow strength (0 disables). Default: 1.6. */
|
||||||
glow?: number;
|
glow?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
|
/** Dim the display uniformly (matches disabled controls). */
|
||||||
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
@ -31,22 +52,40 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
|
||||||
digits = 4,
|
digits = 4,
|
||||||
decimals = 0,
|
decimals = 0,
|
||||||
height = 28,
|
height = 28,
|
||||||
color = '#3df2ad',
|
color,
|
||||||
background = '#0a0d0c',
|
valueColor,
|
||||||
|
zones,
|
||||||
|
weight = 1,
|
||||||
|
gap,
|
||||||
|
background: backgroundProp,
|
||||||
ghostOpacity = 0.09,
|
ghostOpacity = 0.09,
|
||||||
skew = 6,
|
skew = 6,
|
||||||
glow = 1.6,
|
glow = 1.6,
|
||||||
padding = 6,
|
padding = 6,
|
||||||
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
style,
|
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 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 =
|
const text =
|
||||||
typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value;
|
typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value;
|
||||||
const { nodes, width, height: cellH } = renderSegmentText(text, {
|
const { nodes, width, height: cellH } = renderSegmentText(text, {
|
||||||
color,
|
color: resolved,
|
||||||
ghostOpacity,
|
ghostOpacity,
|
||||||
skew,
|
skew,
|
||||||
|
weight,
|
||||||
|
gap,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scale = height / cellH;
|
const scale = height / cellH;
|
||||||
|
|
@ -59,7 +98,8 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
|
||||||
height={h}
|
height={h}
|
||||||
viewBox={`0 0 ${w} ${h}`}
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={typeof value === 'number' ? String(value) : value}
|
aria-label={typeof value === 'number' ? String(value) : value}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
46
packages/dreamknob/src/digital/segments.test.ts
Normal file
46
packages/dreamknob/src/digital/segments.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatForDisplay, zoneColorForValue } from './segments';
|
||||||
|
|
||||||
|
describe('formatForDisplay', () => {
|
||||||
|
it('right-aligns within the digit width', () => {
|
||||||
|
// 2 digits (3,5) fill a 2-cell width — the point is extra, no leading pad.
|
||||||
|
expect(formatForDisplay(3.5, 2, 1)).toBe('3.5');
|
||||||
|
expect(formatForDisplay(42, 3, 0)).toBe(' 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overflows to dashes when it does not fit', () => {
|
||||||
|
expect(formatForDisplay(12345, 3, 0)).toBe('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count the decimal point toward the digit budget', () => {
|
||||||
|
// 3 digits (1,2,3) + a point still fits digits=3.
|
||||||
|
expect(formatForDisplay(12.3, 3, 1)).toBe('12.3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zoneColorForValue', () => {
|
||||||
|
const zones = [
|
||||||
|
{ upTo: 74, color: 'green' },
|
||||||
|
{ upTo: 89, color: 'amber' },
|
||||||
|
{ upTo: Infinity, color: 'red' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('picks the zone whose upper bound the value falls under', () => {
|
||||||
|
expect(zoneColorForValue(zones, 40)).toBe('green');
|
||||||
|
expect(zoneColorForValue(zones, 80)).toBe('amber');
|
||||||
|
expect(zoneColorForValue(zones, 99)).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is inclusive at the boundary', () => {
|
||||||
|
expect(zoneColorForValue(zones, 74)).toBe('green');
|
||||||
|
expect(zoneColorForValue(zones, 89)).toBe('amber');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts unordered zones before resolving', () => {
|
||||||
|
const unordered = [
|
||||||
|
{ upTo: Infinity, color: 'red' },
|
||||||
|
{ upTo: 74, color: 'green' },
|
||||||
|
];
|
||||||
|
expect(zoneColorForValue(unordered, 10)).toBe('green');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,7 +6,9 @@ import * as React from 'react';
|
||||||
|
|
||||||
const DIGIT_W = 10;
|
const DIGIT_W = 10;
|
||||||
const DIGIT_H = 18;
|
const DIGIT_H = 18;
|
||||||
const GAP = 3.2; // spacing between digit cells (leaves room for decimal points)
|
const DEFAULT_GAP = 3.2; // spacing between digit cells (leaves room for points)
|
||||||
|
const BASE_HALF = 1.1; // half segment thickness at weight 1
|
||||||
|
const INSET = 0.45; // gap between adjacent segments
|
||||||
|
|
||||||
type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g';
|
type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g';
|
||||||
|
|
||||||
|
|
@ -57,30 +59,30 @@ const CHAR_SEGMENTS: Record<string, SegmentKey[]> = {
|
||||||
const segmentsFor = (ch: string): SegmentKey[] =>
|
const segmentsFor = (ch: string): SegmentKey[] =>
|
||||||
CHAR_SEGMENTS[ch] ?? CHAR_SEGMENTS[ch.toUpperCase()] ?? CHAR_SEGMENTS[ch.toLowerCase()] ?? [];
|
CHAR_SEGMENTS[ch] ?? CHAR_SEGMENTS[ch.toUpperCase()] ?? CHAR_SEGMENTS[ch.toLowerCase()] ?? [];
|
||||||
|
|
||||||
const H_HALF = 1.1; // half thickness
|
const hSegment = (y: number, half: number): string => {
|
||||||
const INSET = 0.45; // gap between adjacent segments
|
|
||||||
|
|
||||||
const hSegment = (y: number): string => {
|
|
||||||
const x1 = 1 + INSET;
|
const x1 = 1 + INSET;
|
||||||
const x2 = DIGIT_W - 1 - INSET;
|
const x2 = DIGIT_W - 1 - INSET;
|
||||||
return `${x1},${y} ${x1 + H_HALF},${y - H_HALF} ${x2 - H_HALF},${y - H_HALF} ${x2},${y} ${x2 - H_HALF},${y + H_HALF} ${x1 + H_HALF},${y + H_HALF}`;
|
return `${x1},${y} ${x1 + half},${y - half} ${x2 - half},${y - half} ${x2},${y} ${x2 - half},${y + half} ${x1 + half},${y + half}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const vSegment = (x: number, y1: number, y2: number): string => {
|
const vSegment = (x: number, y1: number, y2: number, half: number): string => {
|
||||||
const a = y1 + INSET;
|
const a = y1 + INSET;
|
||||||
const b = y2 - INSET;
|
const b = y2 - INSET;
|
||||||
return `${x},${a} ${x + H_HALF},${a + H_HALF} ${x + H_HALF},${b - H_HALF} ${x},${b} ${x - H_HALF},${b - H_HALF} ${x - H_HALF},${a + H_HALF}`;
|
return `${x},${a} ${x + half},${a + half} ${x + half},${b - half} ${x},${b} ${x - half},${b - half} ${x - half},${a + half}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEGMENT_POINTS: Record<SegmentKey, string> = {
|
/** Build the seven segment polygons for a given half-thickness (weight). */
|
||||||
a: hSegment(1),
|
const buildSegments = (half: number): Record<SegmentKey, string> => ({
|
||||||
g: hSegment(DIGIT_H / 2),
|
a: hSegment(1, half),
|
||||||
d: hSegment(DIGIT_H - 1),
|
g: hSegment(DIGIT_H / 2, half),
|
||||||
f: vSegment(1, 1, DIGIT_H / 2),
|
d: hSegment(DIGIT_H - 1, half),
|
||||||
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2),
|
f: vSegment(1, 1, DIGIT_H / 2, half),
|
||||||
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1),
|
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2, half),
|
||||||
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1),
|
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1, half),
|
||||||
};
|
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1, half),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SEGMENT_ORDER: SegmentKey[] = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
|
||||||
|
|
||||||
export interface SegmentRenderOptions {
|
export interface SegmentRenderOptions {
|
||||||
color: string;
|
color: string;
|
||||||
|
|
@ -88,40 +90,69 @@ export interface SegmentRenderOptions {
|
||||||
ghostOpacity: number;
|
ghostOpacity: number;
|
||||||
/** Negative skew for the classic italic LCD look, in degrees. */
|
/** Negative skew for the classic italic LCD look, in degrees. */
|
||||||
skew: number;
|
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
|
* Render `text` (digits, '-', '.', ':', ' ') as seven-segment polygons in
|
||||||
* coordinates (digit height 18). Returns the nodes plus the total width.
|
* local coordinates (digit height 18). The decimal point and colon are drawn
|
||||||
|
* as bold cells with their own width so they hold up at small sizes rather
|
||||||
|
* than vanishing between digits. Returns the nodes plus the total width.
|
||||||
*/
|
*/
|
||||||
export const renderSegmentText = (
|
export const renderSegmentText = (
|
||||||
text: string,
|
text: string,
|
||||||
{ color, ghostOpacity, skew }: SegmentRenderOptions,
|
{ color, ghostOpacity, skew, weight = 1, gap = DEFAULT_GAP }: SegmentRenderOptions,
|
||||||
): { nodes: React.ReactNode; width: number; height: number } => {
|
): { nodes: React.ReactNode; width: number; height: number } => {
|
||||||
|
const half = BASE_HALF * weight;
|
||||||
|
const segPoints = buildSegments(half);
|
||||||
const cells: React.ReactNode[] = [];
|
const cells: React.ReactNode[] = [];
|
||||||
let x = 0;
|
let x = 0;
|
||||||
let key = 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) {
|
for (const ch of text) {
|
||||||
if (ch === '.') {
|
if (ch === '.') {
|
||||||
// Decimal point sits in the gap after the previous digit.
|
|
||||||
cells.push(
|
cells.push(
|
||||||
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={DIGIT_H - 1.2} r={1.2} fill={color} />,
|
<rect
|
||||||
|
key={key++}
|
||||||
|
x={x - dot}
|
||||||
|
y={DIGIT_H - dot * 2}
|
||||||
|
width={dot * 2}
|
||||||
|
height={dot * 2}
|
||||||
|
rx={dot * 0.5}
|
||||||
|
fill={color}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
// A little breathing room so the following digit doesn't crowd the point.
|
||||||
|
x += dot * 2 + Math.min(gap * 0.6, 2.4);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ':') {
|
||||||
|
const cx = x + dot;
|
||||||
|
cells.push(
|
||||||
|
<g key={key++}>
|
||||||
|
<rect x={cx - dot} y={DIGIT_H * 0.3 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={color} />
|
||||||
|
<rect x={cx - dot} y={DIGIT_H * 0.72 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={color} />
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
x += dot * 2 + gap;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lit = new Set(segmentsFor(ch));
|
const lit = new Set(segmentsFor(ch));
|
||||||
const digit: React.ReactNode[] = [];
|
const digit: React.ReactNode[] = [];
|
||||||
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
|
SEGMENT_ORDER.forEach(seg => {
|
||||||
const on = lit.has(seg);
|
const on = lit.has(seg);
|
||||||
if (!on && ghostOpacity <= 0) return;
|
if (!on && ghostOpacity <= 0) return;
|
||||||
digit.push(
|
digit.push(
|
||||||
<polygon
|
<polygon key={seg} points={segPoints[seg]} fill={color} opacity={on ? 1 : ghostOpacity} />,
|
||||||
key={seg}
|
|
||||||
points={SEGMENT_POINTS[seg]}
|
|
||||||
fill={color}
|
|
||||||
opacity={on ? 1 : ghostOpacity}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
cells.push(
|
cells.push(
|
||||||
|
|
@ -129,18 +160,26 @@ export const renderSegmentText = (
|
||||||
{digit}
|
{digit}
|
||||||
</g>,
|
</g>,
|
||||||
);
|
);
|
||||||
x += DIGIT_W + GAP;
|
x += DIGIT_W + gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = Math.max(x - GAP, 0);
|
const width = Math.max(x - gap, 0);
|
||||||
const nodes = skew ? (
|
const nodes = skew ? <g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g> : <>{cells}</>;
|
||||||
<g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g>
|
|
||||||
) : (
|
|
||||||
<>{cells}</>
|
|
||||||
);
|
|
||||||
return { nodes, width, height: DIGIT_H };
|
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". */
|
/** Format a number for a fixed-width display, e.g. (3.5, 2, 1) -> " 3.5". */
|
||||||
export const formatForDisplay = (
|
export const formatForDisplay = (
|
||||||
value: number,
|
value: number,
|
||||||
|
|
@ -148,7 +187,7 @@ export const formatForDisplay = (
|
||||||
decimals: number,
|
decimals: number,
|
||||||
): string => {
|
): string => {
|
||||||
let text = value.toFixed(decimals);
|
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
|
// Doesn't fit: dashes, the hardware overflow convention. All-8s would read
|
||||||
// as a legitimate value.
|
// as a legitimate value.
|
||||||
if (cellCount(text) > digits) text = ''.padStart(digits, '-');
|
if (cellCount(text) > digits) text = ''.padStart(digits, '-');
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,10 @@ export interface UseKnobResult extends KnobState {
|
||||||
'data-focus-visible'?: string;
|
'data-focus-visible'?: string;
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
};
|
};
|
||||||
/** Imperatively set the value (snapped + clamped). */
|
/** Imperatively set the value (snapped + clamped). Source: 'api' — no Start/End. */
|
||||||
setValue: (value: number) => void;
|
setValue: (value: number) => void;
|
||||||
|
/** Commit a value as an edit gesture: fires onChangeStart → onChange → onChangeEnd with source 'edit'. */
|
||||||
|
commitValue: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragSession {
|
interface DragSession {
|
||||||
|
|
@ -94,6 +96,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
detentSize = 0.025,
|
detentSize = 0.025,
|
||||||
wrap = false,
|
wrap = false,
|
||||||
animateChanges = false,
|
animateChanges = false,
|
||||||
|
commitMode = 'live',
|
||||||
fineMultiplier = 0.1,
|
fineMultiplier = 0.1,
|
||||||
angleOffset = 225,
|
angleOffset = 225,
|
||||||
angleRange = 270,
|
angleRange = 270,
|
||||||
|
|
@ -191,10 +194,33 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
detents?.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1)),
|
detents?.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1)),
|
||||||
[detents, taper, min, max],
|
[detents, taper, min, max],
|
||||||
);
|
);
|
||||||
|
// commitMode 'release': drags update this visual preview; onChange fires
|
||||||
|
// once at pointer-up with the final value.
|
||||||
|
const [previewValue, setPreviewValue] = React.useState<number | null>(null);
|
||||||
|
const previewRef = React.useRef<number | null>(null);
|
||||||
|
previewRef.current = previewValue;
|
||||||
|
|
||||||
const setFromDrag = React.useCallback(
|
const setFromDrag = React.useCallback(
|
||||||
(n: number) =>
|
(n: number) => {
|
||||||
setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n, 'drag'),
|
const nn = detentsN ? applyDetents(n, detentsN, detentSize) : n;
|
||||||
[setFromNormalized, detentsN, detentSize],
|
if (commitMode === 'release') {
|
||||||
|
setPreviewValue(constrain(taper.fromNormalized(wrapN(nn), min, max)));
|
||||||
|
} else {
|
||||||
|
setFromNormalized(nn, 'drag');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setFromNormalized, detentsN, detentSize, commitMode, constrain, taper, wrapN, min, max],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Commit a value from a type-in editor: full edit-gesture bracketing. */
|
||||||
|
const commitValue = React.useCallback(
|
||||||
|
(v: number) => {
|
||||||
|
const target = constrain(v);
|
||||||
|
onChangeStartRef.current?.(valueRef.current, { source: 'edit' });
|
||||||
|
emit(target, 'edit');
|
||||||
|
onChangeEndRef.current?.(target, { source: 'edit' });
|
||||||
|
},
|
||||||
|
[constrain, emit],
|
||||||
);
|
);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -334,12 +360,23 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
[interactive, applyPointer],
|
[interactive, applyPointer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const endDrag = React.useCallback((e: React.PointerEvent) => {
|
const endDrag = React.useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||||
session.current = null;
|
session.current = null;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
const pending = previewRef.current;
|
||||||
|
if (pending !== null) {
|
||||||
|
// release-mode: commit exactly once, now.
|
||||||
|
setPreviewValue(null);
|
||||||
|
emit(pending, 'drag');
|
||||||
|
onChangeEndRef.current?.(pending, { source: 'drag' });
|
||||||
|
} else {
|
||||||
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
||||||
}, []);
|
}
|
||||||
|
},
|
||||||
|
[emit],
|
||||||
|
);
|
||||||
|
|
||||||
// If the control becomes disabled/readOnly mid-gesture, cancel the drag —
|
// If the control becomes disabled/readOnly mid-gesture, cancel the drag —
|
||||||
// otherwise it would keep emitting while rendered as disabled.
|
// otherwise it would keep emitting while rendered as disabled.
|
||||||
|
|
@ -347,6 +384,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
if (interactive || !session.current) return;
|
if (interactive || !session.current) return;
|
||||||
session.current = null;
|
session.current = null;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setPreviewValue(null);
|
||||||
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
|
||||||
}, [interactive]);
|
}, [interactive]);
|
||||||
|
|
||||||
|
|
@ -473,7 +511,12 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
session.current = null;
|
session.current = null;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
if (previewRef.current !== null) {
|
||||||
|
// release-mode: nothing was committed — just discard the preview.
|
||||||
|
setPreviewValue(null);
|
||||||
|
} else {
|
||||||
emit(s.startValue, 'drag');
|
emit(s.startValue, 'drag');
|
||||||
|
}
|
||||||
onChangeEndRef.current?.(s.startValue, { source: 'drag' });
|
onChangeEndRef.current?.(s.startValue, { source: 'drag' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -594,7 +637,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
};
|
};
|
||||||
}, [value, animMs]);
|
}, [value, animMs]);
|
||||||
|
|
||||||
const shownValue = animMs ? displayValue : value;
|
const shownValue = previewValue ?? (animMs ? displayValue : value);
|
||||||
const normalized = clamp(taper.toNormalized(shownValue, min, max), 0, 1);
|
const normalized = clamp(taper.toNormalized(shownValue, min, max), 0, 1);
|
||||||
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
||||||
const originNormalized =
|
const originNormalized =
|
||||||
|
|
@ -603,7 +646,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
: clamp(taper.toNormalized(clamp(origin, min, max), min, max), 0, 1);
|
: clamp(taper.toNormalized(clamp(origin, min, max), min, max), 0, 1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value,
|
value: previewValue ?? value,
|
||||||
normalized,
|
normalized,
|
||||||
originNormalized,
|
originNormalized,
|
||||||
angle,
|
angle,
|
||||||
|
|
@ -616,6 +659,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
angleRange,
|
angleRange,
|
||||||
ref,
|
ref,
|
||||||
setValue: emit,
|
setValue: emit,
|
||||||
|
commitValue,
|
||||||
bind: {
|
bind: {
|
||||||
ref,
|
ref,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
|
|
@ -631,7 +675,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
tabIndex: disabled ? -1 : 0,
|
tabIndex: disabled ? -1 : 0,
|
||||||
'aria-valuemin': min,
|
'aria-valuemin': min,
|
||||||
'aria-valuemax': max,
|
'aria-valuemax': max,
|
||||||
'aria-valuenow': value,
|
'aria-valuenow': previewValue ?? value,
|
||||||
'aria-orientation':
|
'aria-orientation':
|
||||||
interaction === 'track-vertical'
|
interaction === 'track-vertical'
|
||||||
? ('vertical' as const)
|
? ('vertical' as const)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export type {
|
||||||
ChangeSource,
|
ChangeSource,
|
||||||
} from './core/types';
|
} from './core/types';
|
||||||
export { mixColors, sampleGradient, type GradientStop } from './core/colors';
|
export { mixColors, sampleGradient, type GradientStop } from './core/colors';
|
||||||
|
export type { DisplayZone } from './digital/segments';
|
||||||
export {
|
export {
|
||||||
clamp,
|
clamp,
|
||||||
roundTo,
|
roundTo,
|
||||||
|
|
@ -63,10 +64,28 @@ export { VintageKnob, type VintageKnobProps } from './skins/VintageKnob';
|
||||||
export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob';
|
export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob';
|
||||||
export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
|
export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
|
||||||
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
|
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
|
||||||
|
export { PanKnob, type PanKnobProps } from './skins/PanKnob';
|
||||||
export { Fader, type FaderProps } from './skins/Fader';
|
export { Fader, type FaderProps } from './skins/Fader';
|
||||||
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
|
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
|
||||||
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
|
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
|
||||||
export { Meter, type MeterProps } from './skins/Meter';
|
export { Meter, type MeterProps } from './skins/Meter';
|
||||||
export { PushButton, type PushButtonProps } from './skins/PushButton';
|
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 { 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 { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
|
||||||
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
export { LabeledField, type LabeledFieldProps } from './layout/LabeledField';
|
||||||
|
export { Rack, type RackProps } from './layout/Rack';
|
||||||
|
|
|
||||||
91
packages/dreamknob/src/layout/LabeledField.tsx
Normal file
91
packages/dreamknob/src/layout/LabeledField.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
|
export interface LabeledFieldProps {
|
||||||
|
/** Field name printed beside the control. */
|
||||||
|
label: string;
|
||||||
|
/** The control (a knob, fader, ScrubField, ToggleSwitch, …). */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Unit printed after the control, e.g. "px", "dB". */
|
||||||
|
unit?: string;
|
||||||
|
/**
|
||||||
|
* Label column width — set the same value on a stack of fields so their
|
||||||
|
* controls line up. Number = px. Default: auto.
|
||||||
|
*/
|
||||||
|
labelWidth?: number | string;
|
||||||
|
/** 'row' (label · control · unit, default) or 'column' (label above). */
|
||||||
|
orientation?: 'row' | 'column';
|
||||||
|
/** Push the control to the right edge of the row. Default: false. */
|
||||||
|
spread?: boolean;
|
||||||
|
gap?: number;
|
||||||
|
labelColor?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One aligned inspector row: a label, a control and an optional unit. Give a
|
||||||
|
* stack of fields the same `labelWidth` and their controls line up — the
|
||||||
|
* boilerplate every properties panel rewrites, owned in one place.
|
||||||
|
*/
|
||||||
|
export const LabeledField: React.FC<LabeledFieldProps> = ({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
unit,
|
||||||
|
labelWidth,
|
||||||
|
orientation = 'row',
|
||||||
|
spread = false,
|
||||||
|
gap = 10,
|
||||||
|
labelColor,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const column = orientation === 'column';
|
||||||
|
const labelEl = (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
width: column ? undefined : labelWidth,
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: labelColor ?? theme.label,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: column ? 'column' : 'row',
|
||||||
|
alignItems: column ? 'flex-start' : 'center',
|
||||||
|
gap: column ? 4 : gap,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labelEl}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginLeft: spread && !column ? 'auto' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{unit && (
|
||||||
|
<span style={{ fontFamily: theme.fontMono, fontSize: 11, color: theme.label, whiteSpace: 'nowrap' }}>
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
packages/dreamknob/src/layout/Rack.tsx
Normal file
96
packages/dreamknob/src/layout/Rack.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
|
export interface RackProps {
|
||||||
|
/** 'row' lays out strips side by side (a rack); 'column' stacks a strip. Default: 'row'. */
|
||||||
|
orientation?: 'row' | 'column';
|
||||||
|
/** Panel caption printed at the top of the chrome. */
|
||||||
|
title?: string;
|
||||||
|
/** Spacing between children in px. Default: 14. */
|
||||||
|
gap?: number;
|
||||||
|
/** Inner padding in px. Default: 14. */
|
||||||
|
padding?: number;
|
||||||
|
/** Draw the panel chrome (border + background). Default: true. */
|
||||||
|
chrome?: boolean;
|
||||||
|
/** Cross-axis alignment. Default: 'stretch' for column, 'flex-start' for row. */
|
||||||
|
align?: React.CSSProperties['alignItems'];
|
||||||
|
accentColor?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rack / channel-strip container that owns the panel chrome and spacing, so a
|
||||||
|
* console section is declarative: nest `Rack`s (a row of column strips) and the
|
||||||
|
* borders, background and gaps come for free.
|
||||||
|
*/
|
||||||
|
export const Rack: React.FC<RackProps> = ({
|
||||||
|
orientation = 'row',
|
||||||
|
title,
|
||||||
|
gap = 14,
|
||||||
|
padding = 14,
|
||||||
|
chrome = true,
|
||||||
|
align,
|
||||||
|
accentColor,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const column = orientation === 'column';
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: title ? 10 : 0,
|
||||||
|
padding: chrome ? padding : 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: chrome ? theme.panel : undefined,
|
||||||
|
border: chrome ? '1px solid rgba(128,128,128,0.18)' : undefined,
|
||||||
|
boxShadow: chrome ? 'inset 0 1px 0 rgba(255,255,255,0.04)' : undefined,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.14em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: theme.label,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: accentColor ?? theme.accent ?? theme.zoneGood,
|
||||||
|
flex: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{title}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: column ? 'column' : 'row',
|
||||||
|
alignItems: align ?? (column ? 'stretch' : 'flex-start'),
|
||||||
|
gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -66,27 +66,43 @@ export interface KnobLabelProps {
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
/** Vertical offset from center; defaults to just below the knob body. */
|
/** Vertical offset from center; defaults to just below the knob body. */
|
||||||
dy?: number;
|
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>;
|
textProps?: React.SVGProps<SVGTextElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small caption, e.g. the parameter name. */
|
/** Small caption, e.g. the parameter name — with an optional second line. */
|
||||||
export const KnobLabel: React.FC<KnobLabelProps> = ({
|
export const KnobLabel: React.FC<KnobLabelProps> = ({
|
||||||
children,
|
children,
|
||||||
fontSize,
|
fontSize,
|
||||||
color = 'currentColor',
|
color = 'currentColor',
|
||||||
fontFamily,
|
fontFamily,
|
||||||
dy,
|
dy,
|
||||||
|
sublabel,
|
||||||
|
subFontSize,
|
||||||
|
subColor,
|
||||||
textProps,
|
textProps,
|
||||||
}) => {
|
}) => {
|
||||||
const { size, center } = useKnobContext();
|
const { size, center } = useKnobContext();
|
||||||
|
const fs = fontSize ?? size * 0.12;
|
||||||
|
const y = center + (dy ?? size * 0.38);
|
||||||
|
const subFs = subFontSize ?? fs * 0.82;
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<text
|
<text
|
||||||
data-part="label"
|
data-part="label"
|
||||||
x={center}
|
x={center}
|
||||||
y={center + (dy ?? size * 0.38)}
|
y={y}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize={fontSize ?? size * 0.12}
|
fontSize={fs}
|
||||||
fill={color}
|
fill={color}
|
||||||
fontFamily={fontFamily}
|
fontFamily={fontFamily}
|
||||||
letterSpacing="0.08em"
|
letterSpacing="0.08em"
|
||||||
|
|
@ -95,5 +111,23 @@ export const KnobLabel: React.FC<KnobLabelProps> = ({
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</text>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
focusRing?: string | false;
|
focusRing?: string | false;
|
||||||
/** Travel length in px. Default: 160. */
|
/** Travel length in px. Default: 160. */
|
||||||
length?: number;
|
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. */
|
/** Cross-axis size in px. Default: 44. */
|
||||||
breadth?: number;
|
breadth?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|
@ -28,6 +34,12 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
format?: (value: number) => string;
|
format?: (value: number) => string;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
|
/**
|
||||||
|
* Where the value readout sits: 'top' (default), 'end' (after the track —
|
||||||
|
* inline to the right for horizontal faders, below for vertical), or
|
||||||
|
* 'none'.
|
||||||
|
*/
|
||||||
|
valuePosition?: 'top' | 'end' | 'none';
|
||||||
/** Click the readout to type an exact value. */
|
/** Click the readout to type an exact value. */
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
/** Custom parser for typed input. */
|
/** Custom parser for typed input. */
|
||||||
|
|
@ -43,6 +55,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
focusRing,
|
focusRing,
|
||||||
length = 160,
|
length = 160,
|
||||||
|
fill = false,
|
||||||
breadth = 44,
|
breadth = 44,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
trackColor,
|
trackColor,
|
||||||
|
|
@ -57,6 +70,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
format,
|
format,
|
||||||
textColor,
|
textColor,
|
||||||
labelColor,
|
labelColor,
|
||||||
|
valuePosition = 'top',
|
||||||
editable = false,
|
editable = false,
|
||||||
parseValue,
|
parseValue,
|
||||||
name,
|
name,
|
||||||
|
|
@ -78,7 +92,11 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
// Align pointer mapping with the handle's travel so grabbing the cap
|
// Align pointer mapping with the handle's travel so grabbing the cap
|
||||||
// doesn't jump the value.
|
// doesn't jump the value.
|
||||||
trackInset: capMain / 2,
|
trackInset: capMain / 2,
|
||||||
|
getAriaValueText:
|
||||||
|
core.getAriaValueText ??
|
||||||
|
(format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined),
|
||||||
});
|
});
|
||||||
|
const showReadout = showValue && valuePosition !== 'none';
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
|
|
||||||
const capCross = breadth * 0.62;
|
const capCross = breadth * 0.62;
|
||||||
|
|
@ -86,6 +104,9 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
const w = vertical ? breadth : length;
|
const w = vertical ? breadth : length;
|
||||||
const h = vertical ? length : breadth;
|
const h = vertical ? length : breadth;
|
||||||
const mid = breadth / 2;
|
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.
|
// Handle center position along the travel axis.
|
||||||
const travel = length - capMain;
|
const travel = length - capMain;
|
||||||
|
|
@ -120,28 +141,17 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const atEnd = valuePosition === 'end';
|
||||||
<div
|
const readout = showReadout && (
|
||||||
className={className}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
// Fixed width so a widening value readout never reflows the layout;
|
|
||||||
// overflowing text spills symmetrically via the centered flex rows.
|
|
||||||
width: w,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showValue && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: atEnd ? undefined : '100%',
|
||||||
|
minWidth: atEnd ? '5.5ch' : undefined,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: atEnd ? 'flex-start' : 'center',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
minHeight: '1.4em',
|
minHeight: '1.4em',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|
@ -149,11 +159,11 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
initial={String(knob.value)}
|
initial={String(knob.value)}
|
||||||
onCommit={raw => {
|
onCommit={raw => {
|
||||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||||
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}}
|
}}
|
||||||
onCancel={() => setEditing(false)}
|
onCancel={() => setEditing(false)}
|
||||||
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
|
style={{ position: 'static', transform: 'none', width: atEnd ? '9ch' : '100%', fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
|
|
@ -173,7 +183,9 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
|
||||||
|
const control = (
|
||||||
<div
|
<div
|
||||||
{...knob.bind}
|
{...knob.bind}
|
||||||
ref={node => {
|
ref={node => {
|
||||||
|
|
@ -183,14 +195,22 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
...knob.bind.style,
|
...knob.bind.style,
|
||||||
width: w,
|
opacity: undefined, // the outer wrapper owns disabled dimming
|
||||||
height: h,
|
width: ctrlW,
|
||||||
display: 'inline-flex',
|
height: ctrlH,
|
||||||
|
display: fill ? 'flex' : 'inline-flex',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
<svg
|
||||||
|
width={ctrlW}
|
||||||
|
height={ctrlH}
|
||||||
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
|
preserveAspectRatio={fill ? 'none' : undefined}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<defs>
|
<defs>
|
||||||
{capColor ? (
|
{capColor ? (
|
||||||
// Custom cap: solid base with a neutral shading overlay so any
|
// Custom cap: solid base with a neutral shading overlay so any
|
||||||
|
|
@ -261,6 +281,37 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
</svg>
|
</svg>
|
||||||
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
data-disabled={core.disabled ? '' : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
// Fixed width ('top' mode) so a widening readout never reflows the
|
||||||
|
// layout; 'end' mode sizes to content with a min-width readout slot.
|
||||||
|
width: fill && !vertical ? '100%' : atEnd && !vertical ? undefined : w,
|
||||||
|
height: fill && vertical ? '100%' : undefined,
|
||||||
|
opacity: core.disabled ? 0.45 : undefined,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valuePosition === 'top' && readout}
|
||||||
|
{atEnd && !vertical ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{control}
|
||||||
|
{readout}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{control}
|
||||||
|
{atEnd && readout}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
|
||||||
arcFrom = 'min',
|
arcFrom = 'min',
|
||||||
arcThickness,
|
arcThickness,
|
||||||
label,
|
label,
|
||||||
|
sublabel,
|
||||||
|
labelSize,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
|
|
@ -51,6 +53,7 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
|
||||||
style={style}
|
style={style}
|
||||||
focusRing={focusRing ?? theme.focusRing}
|
focusRing={focusRing ?? theme.focusRing}
|
||||||
{...core}
|
{...core}
|
||||||
|
getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}
|
||||||
>
|
>
|
||||||
<Arc
|
<Arc
|
||||||
thickness={t}
|
thickness={t}
|
||||||
|
|
@ -80,7 +83,8 @@ export const FlatKnob = React.forwardRef<HTMLDivElement, FlatKnobProps>(function
|
||||||
color={labelColor ?? theme.label}
|
color={labelColor ?? theme.label}
|
||||||
fontFamily={theme.fontUI}
|
fontFamily={theme.fontUI}
|
||||||
dy={size * 0.42}
|
dy={size * 0.42}
|
||||||
fontSize={size * 0.1}
|
fontSize={labelSize ?? size * 0.1}
|
||||||
|
sublabel={sublabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
|
|
|
||||||
191
packages/dreamknob/src/skins/Gauge.tsx
Normal file
191
packages/dreamknob/src/skins/Gauge.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -62,7 +62,12 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
||||||
...core
|
...core
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const knob = useKnob(core);
|
const knob = useKnob({
|
||||||
|
...core,
|
||||||
|
getAriaValueText:
|
||||||
|
core.getAriaValueText ??
|
||||||
|
(format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined),
|
||||||
|
});
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
const ring = focusRing ?? theme.focusRing;
|
const ring = focusRing ?? theme.focusRing;
|
||||||
|
|
||||||
|
|
@ -82,12 +87,14 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
|
data-disabled={core.disabled ? '' : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
width: size,
|
width: size,
|
||||||
|
opacity: core.disabled ? 0.45 : undefined,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -108,6 +115,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
...knob.bind.style,
|
...knob.bind.style,
|
||||||
|
opacity: undefined, // the outer wrapper owns disabled dimming
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
|
|
@ -133,7 +141,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
||||||
initial={String(knob.value)}
|
initial={String(knob.value)}
|
||||||
onCommit={raw => {
|
onCommit={raw => {
|
||||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||||
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
|
||||||
closeEditor();
|
closeEditor();
|
||||||
}}
|
}}
|
||||||
onCancel={closeEditor}
|
onCancel={closeEditor}
|
||||||
|
|
|
||||||
191
packages/dreamknob/src/skins/IndicatorLamp.tsx
Normal file
191
packages/dreamknob/src/skins/IndicatorLamp.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
|
export interface IndicatorLampProps {
|
||||||
|
/** Controlled on-state. Pair with `onChange`. */
|
||||||
|
on?: boolean;
|
||||||
|
defaultOn?: boolean;
|
||||||
|
onChange?: (on: boolean) => void;
|
||||||
|
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
|
||||||
|
mode?: 'toggle' | 'momentary';
|
||||||
|
/** Lamp color when lit. */
|
||||||
|
color?: string;
|
||||||
|
/** Lamp diameter in px. Default: 12. */
|
||||||
|
size?: number;
|
||||||
|
shape?: 'round' | 'square';
|
||||||
|
/** Pulse while lit — armed/recording states. Respects prefers-reduced-motion. */
|
||||||
|
blink?: boolean;
|
||||||
|
/** Pure status light: not pressable, not focusable, no switch semantics. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
/** Caption beside the lamp. */
|
||||||
|
label?: string;
|
||||||
|
/**
|
||||||
|
* 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". */
|
||||||
|
name?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pressable LED lamp — the hardware-real toggle for dense headers and
|
||||||
|
* checkbox duty (`● CUE ● MUTE`). Renders `role="switch"`.
|
||||||
|
*/
|
||||||
|
export const IndicatorLamp = React.forwardRef<HTMLButtonElement, IndicatorLampProps>(
|
||||||
|
function IndicatorLamp(
|
||||||
|
{
|
||||||
|
on,
|
||||||
|
defaultOn = false,
|
||||||
|
onChange,
|
||||||
|
mode = 'toggle',
|
||||||
|
color,
|
||||||
|
size = 12,
|
||||||
|
shape = 'round',
|
||||||
|
blink = false,
|
||||||
|
readOnly = false,
|
||||||
|
label,
|
||||||
|
labelSize,
|
||||||
|
labelPosition = 'right',
|
||||||
|
disabled = false,
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...aria
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const accent = color ?? theme.accent ?? '#3df2ad';
|
||||||
|
const isControlled = on !== undefined;
|
||||||
|
const [internal, setInternal] = React.useState(defaultOn);
|
||||||
|
const [focusVisible, setFocusVisible] = React.useState(false);
|
||||||
|
const lit = isControlled ? (on as boolean) : internal;
|
||||||
|
const ledRef = React.useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const set = (next: boolean) => {
|
||||||
|
if (next === lit) return;
|
||||||
|
if (!isControlled) setInternal(next);
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Blink via the Web Animations API — no stylesheet injection needed.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = ledRef.current;
|
||||||
|
if (!el || !blink || !lit) return;
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||||
|
return;
|
||||||
|
const anim = el.animate([{ opacity: 1 }, { opacity: 0.25 }], {
|
||||||
|
duration: 550,
|
||||||
|
iterations: Infinity,
|
||||||
|
direction: 'alternate',
|
||||||
|
easing: 'ease-in-out',
|
||||||
|
});
|
||||||
|
return () => anim.cancel();
|
||||||
|
}, [blink, lit]);
|
||||||
|
|
||||||
|
const handlers = readOnly
|
||||||
|
? {}
|
||||||
|
: mode === 'momentary'
|
||||||
|
? {
|
||||||
|
onPointerDown: (e: React.PointerEvent) => {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
set(true);
|
||||||
|
},
|
||||||
|
onPointerUp: () => set(false),
|
||||||
|
onPointerCancel: () => set(false),
|
||||||
|
onKeyDown: (e: React.KeyboardEvent) => {
|
||||||
|
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) set(true);
|
||||||
|
},
|
||||||
|
onKeyUp: (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') set(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { onClick: () => set(!lit) };
|
||||||
|
|
||||||
|
const lamp = (
|
||||||
|
<span
|
||||||
|
ref={ledRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
flex: 'none',
|
||||||
|
borderRadius: shape === 'round' ? '50%' : Math.max(2, size * 0.2),
|
||||||
|
background: lit ? accent : 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid rgba(0,0,0,0.55)',
|
||||||
|
boxShadow: lit
|
||||||
|
? `0 0 ${size * 0.7}px ${accent}, inset 0 0 ${size * 0.25}px rgba(255,255,255,0.5)`
|
||||||
|
: 'inset 0 1px 2px rgba(0,0,0,0.6)',
|
||||||
|
transition: 'background 80ms, box-shadow 80ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
role={readOnly ? 'status' : 'switch'}
|
||||||
|
aria-checked={readOnly ? undefined : lit}
|
||||||
|
aria-label={aria['aria-label'] ?? label}
|
||||||
|
disabled={disabled}
|
||||||
|
tabIndex={readOnly ? -1 : undefined}
|
||||||
|
data-on={lit ? '' : undefined}
|
||||||
|
data-focus-visible={focusVisible ? '' : undefined}
|
||||||
|
className={className}
|
||||||
|
{...handlers}
|
||||||
|
onFocus={e => {
|
||||||
|
try {
|
||||||
|
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||||||
|
} catch {
|
||||||
|
setFocusVisible(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setFocusVisible(false)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: labelPosition === 'left' ? 'row-reverse' : 'row',
|
||||||
|
gap: Math.max(5, size * 0.5),
|
||||||
|
padding: 3,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: disabled ? 'not-allowed' : readOnly ? 'default' : 'pointer',
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
outline: 'none',
|
||||||
|
boxShadow: focusVisible ? `0 0 0 2px ${theme.focusRing}` : undefined,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lamp}
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: labelSize ?? Math.max(10, size * 0.9),
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: lit ? theme.text : theme.label,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
transition: 'color 80ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{name && <input type="hidden" name={name} value={lit ? 'on' : 'off'} readOnly />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -15,6 +15,13 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
orientation?: 'vertical' | 'horizontal';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
/** Travel length in px. Default: 160. */
|
/** Travel length in px. Default: 160. */
|
||||||
length?: number;
|
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. */
|
/** Cross-axis size in px. Default: 34. */
|
||||||
breadth?: number;
|
breadth?: number;
|
||||||
/** Number of LED segments. Default: 24. */
|
/** Number of LED segments. Default: 24. */
|
||||||
|
|
@ -38,8 +45,10 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
showValue?: boolean;
|
showValue?: boolean;
|
||||||
/** Digit cells in the readout. Default: 4. */
|
/** Digit cells in the readout. Default: 4. */
|
||||||
digits?: number;
|
digits?: number;
|
||||||
/** Decimals in the readout (defaults to the control's decimals, capped at 1). */
|
/** Decimals in the readout. Defaults to the control's decimals — size `digits` to fit. */
|
||||||
displayDecimals?: number;
|
displayDecimals?: number;
|
||||||
|
/** Unit suffix beside the readout, e.g. "dB". */
|
||||||
|
unit?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
/** Click the readout to type an exact value. */
|
/** Click the readout to type an exact value. */
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
|
|
@ -55,18 +64,20 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
|
export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function LEDFader({
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
length = 160,
|
length = 160,
|
||||||
|
fill = false,
|
||||||
breadth = 34,
|
breadth = 34,
|
||||||
segments = 24,
|
segments = 24,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
zones,
|
zones,
|
||||||
offOpacity = 0.13,
|
offOpacity = 0.13,
|
||||||
glow = true,
|
glow = true,
|
||||||
faceColor = '#0b0d0e',
|
faceColor: faceColorProp,
|
||||||
focusRing,
|
focusRing,
|
||||||
label,
|
label,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
digits = 4,
|
digits = 4,
|
||||||
displayDecimals,
|
displayDecimals,
|
||||||
|
unit,
|
||||||
labelColor,
|
labelColor,
|
||||||
editable = false,
|
editable = false,
|
||||||
parseValue,
|
parseValue,
|
||||||
|
|
@ -77,7 +88,8 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
const color = colorProp ?? theme.accent;
|
||||||
|
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
|
||||||
const ring = focusRing ?? theme.focusRing;
|
const ring = focusRing ?? theme.focusRing;
|
||||||
const vertical = orientation === 'vertical';
|
const vertical = orientation === 'vertical';
|
||||||
const pad = 5;
|
const pad = 5;
|
||||||
|
|
@ -85,16 +97,28 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
...core,
|
...core,
|
||||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
||||||
trackInset: pad,
|
trackInset: pad,
|
||||||
|
getAriaValueText: core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined),
|
||||||
});
|
});
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const glowId = `${id}-glow`;
|
const glowId = `${id}-glow`;
|
||||||
|
|
||||||
const w = vertical ? breadth : length;
|
const w = vertical ? breadth : length;
|
||||||
const h = vertical ? length : breadth;
|
const h = vertical ? length : breadth;
|
||||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(
|
// Fill stretches the travel axis (via preserveAspectRatio='none'); the cross
|
||||||
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
|
// axis stays at `breadth`. The LED geometry is computed in the fixed viewBox
|
||||||
[zones, color],
|
// 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 }];
|
||||||
|
return [
|
||||||
|
{ upTo: 0.72, color: theme.zoneGood },
|
||||||
|
{ upTo: 0.9, color: theme.zoneWarn },
|
||||||
|
{ upTo: 1, color: theme.zoneHot },
|
||||||
|
];
|
||||||
|
}, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]);
|
||||||
const zoneFor = (t: number): string =>
|
const zoneFor = (t: number): string =>
|
||||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||||
|
|
||||||
|
|
@ -142,12 +166,15 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
|
data-disabled={core.disabled ? '' : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
width: w,
|
width: fill && !vertical ? '100%' : w,
|
||||||
|
height: fill && vertical ? '100%' : undefined,
|
||||||
|
opacity: core.disabled ? 0.45 : undefined,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -171,22 +198,29 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
initial={String(knob.value)}
|
initial={String(knob.value)}
|
||||||
onCommit={raw => {
|
onCommit={raw => {
|
||||||
const parsed = (parseValue ?? defaultParseValue)(raw);
|
const parsed = (parseValue ?? defaultParseValue)(raw);
|
||||||
if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
|
if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}}
|
}}
|
||||||
onCancel={() => setEditing(false)}
|
onCancel={() => setEditing(false)}
|
||||||
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
|
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4 }}>
|
||||||
<SegmentDisplay
|
<SegmentDisplay
|
||||||
value={knob.value}
|
value={knob.value}
|
||||||
digits={digits}
|
digits={digits}
|
||||||
decimals={displayDecimals ?? Math.min(knob.decimals, 1)}
|
decimals={displayDecimals ?? knob.decimals}
|
||||||
height={12}
|
height={12}
|
||||||
color={zoneFor(knob.normalized)}
|
color={zoneFor(knob.normalized)}
|
||||||
background="none"
|
background="none"
|
||||||
ghostOpacity={0.07}
|
ghostOpacity={0.07}
|
||||||
/>
|
/>
|
||||||
|
{unit && (
|
||||||
|
<span style={{ fontFamily: theme.fontMono, fontSize: 10, color: theme.label, whiteSpace: 'nowrap' }}>
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -199,14 +233,22 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
...knob.bind.style,
|
...knob.bind.style,
|
||||||
width: w,
|
opacity: undefined, // the outer wrapper owns disabled dimming
|
||||||
height: h,
|
width: ctrlW,
|
||||||
display: 'inline-flex',
|
height: ctrlH,
|
||||||
|
display: fill ? 'flex' : 'inline-flex',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
<svg
|
||||||
|
width={ctrlW}
|
||||||
|
height={ctrlH}
|
||||||
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
|
preserveAspectRatio={fill ? 'none' : undefined}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<filter
|
<filter
|
||||||
id={glowId}
|
id={glowId}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export interface LEDKnobProps extends SkinProps {
|
||||||
segments?: number;
|
segments?: number;
|
||||||
/** Digit cells in the center readout. */
|
/** Digit cells in the center readout. */
|
||||||
digits?: number;
|
digits?: number;
|
||||||
/** Decimals shown in the readout (defaults to the knob's decimals, capped at 2). */
|
/** Decimals shown in the readout. Defaults to the knob's decimals — make sure `digits` has room. */
|
||||||
displayDecimals?: number;
|
displayDecimals?: number;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
faceColor?: string;
|
faceColor?: string;
|
||||||
|
|
@ -90,8 +90,11 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
|
||||||
digits = 3,
|
digits = 3,
|
||||||
displayDecimals,
|
displayDecimals,
|
||||||
labelColor,
|
labelColor,
|
||||||
faceColor = '#0b0d0e',
|
faceColor: faceColorProp,
|
||||||
label,
|
label,
|
||||||
|
sublabel,
|
||||||
|
labelSize,
|
||||||
|
unit,
|
||||||
focusRing,
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
|
@ -99,10 +102,19 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
const color = colorProp ?? theme.accent ?? '#3df2ad';
|
||||||
|
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const glowId = `${id}-glow`;
|
const glowId = `${id}-glow`;
|
||||||
return (
|
return (
|
||||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob
|
||||||
|
ref={ref}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
focusRing={focusRing}
|
||||||
|
{...core}
|
||||||
|
getAriaValueText={core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined)}
|
||||||
|
>
|
||||||
{ctx => (
|
{ctx => (
|
||||||
<>
|
<>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -120,15 +132,29 @@ export const LEDKnob = React.forwardRef<HTMLDivElement, LEDKnobProps>(function L
|
||||||
<CenterDisplay
|
<CenterDisplay
|
||||||
color={color}
|
color={color}
|
||||||
digits={digits}
|
digits={digits}
|
||||||
decimals={displayDecimals ?? Math.min(ctx.decimals, 2)}
|
decimals={displayDecimals ?? ctx.decimals}
|
||||||
glowId={glowId}
|
glowId={glowId}
|
||||||
/>
|
/>
|
||||||
|
{unit && (
|
||||||
|
<text
|
||||||
|
x={ctx.center}
|
||||||
|
y={ctx.center + size * 0.2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={size * 0.08}
|
||||||
|
fill={theme.label}
|
||||||
|
fontFamily={theme.fontMono}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{unit}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<KnobLabel
|
<KnobLabel
|
||||||
color={labelColor ?? theme.label}
|
color={labelColor ?? theme.label}
|
||||||
fontFamily={theme.fontUI}
|
fontFamily={theme.fontUI}
|
||||||
dy={size * 0.45}
|
dy={size * 0.45}
|
||||||
fontSize={size * 0.095}
|
fontSize={labelSize ?? size * 0.095}
|
||||||
|
sublabel={sublabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
|
|
|
||||||
137
packages/dreamknob/src/skins/LampRow.tsx
Normal file
137
packages/dreamknob/src/skins/LampRow.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -33,6 +33,8 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
|
||||||
showArc = true,
|
showArc = true,
|
||||||
tickCount = 21,
|
tickCount = 21,
|
||||||
label,
|
label,
|
||||||
|
sublabel,
|
||||||
|
labelSize,
|
||||||
showValue = false,
|
showValue = false,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
|
|
@ -61,7 +63,7 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
|
||||||
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
|
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1">
|
<linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1">
|
||||||
<stop offset="0" stopColor={rimStops[0]} />
|
<stop offset="0" stopColor={rimStops[0]} />
|
||||||
|
|
@ -137,7 +139,8 @@ export const MetalKnob = React.forwardRef<HTMLDivElement, MetalKnobProps>(functi
|
||||||
color={labelColor ?? theme.label}
|
color={labelColor ?? theme.label}
|
||||||
fontFamily={theme.fontUI}
|
fontFamily={theme.fontUI}
|
||||||
dy={size * 0.44}
|
dy={size * 0.44}
|
||||||
fontSize={size * 0.1}
|
fontSize={labelSize ?? size * 0.1}
|
||||||
|
sublabel={sublabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
|
|
|
||||||
|
|
@ -4,116 +4,211 @@ import type { Taper } from '../core/math';
|
||||||
import { useKnobTheme } from '../core/theme';
|
import { useKnobTheme } from '../core/theme';
|
||||||
import type { LEDFaderZone } from './LEDFader';
|
import type { LEDFaderZone } from './LEDFader';
|
||||||
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
||||||
|
import { ampToDb, useMeterEngine, type Ballistics } from './meterEngine';
|
||||||
|
|
||||||
export interface MeterProps {
|
export interface MeterProps {
|
||||||
/** Level to display. The meter is read-only — always controlled. */
|
/**
|
||||||
value: number;
|
* Level to display — a single number, or an array for a multi-channel
|
||||||
|
* meter (e.g. `[l, r]` for stereo). The meter is read-only. With
|
||||||
|
* `scale="db"` these are linear amplitudes (0..1); otherwise they are in the
|
||||||
|
* `min`..`max` domain directly.
|
||||||
|
*/
|
||||||
|
value: number | readonly number[];
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
taper?: Taper;
|
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';
|
orientation?: 'vertical' | 'horizontal';
|
||||||
/** Travel length in px. Default: 160. */
|
/** Travel length in px. Default: 160. */
|
||||||
length?: number;
|
length?: number;
|
||||||
/** Cross-axis size in px. Default: 16. */
|
/**
|
||||||
|
* 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;
|
breadth?: number;
|
||||||
/** Number of LED segments. Default: 28. */
|
/** Number of LED segments. Default: 28. */
|
||||||
segments?: number;
|
segments?: number;
|
||||||
/** LED color when no zones are given. */
|
/** LED color when no zones are given. Defaults to the theme's zone colors. */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Meter zones, e.g. green / yellow / red by normalized position. */
|
/** Meter zones by normalized position. Defaults to theme zoneGood/Warn/Hot. */
|
||||||
zones?: readonly LEDFaderZone[];
|
zones?: readonly LEDFaderZone[];
|
||||||
/** Opacity of unlit segments. Default: 0.1. */
|
/** Opacity of unlit segments. Default: 0.1. */
|
||||||
offOpacity?: number;
|
offOpacity?: number;
|
||||||
|
/**
|
||||||
|
* 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
|
* Peak hold time in ms — the highest recent segment stays lit and falls
|
||||||
* back after this long. `false` disables. Default: 1200.
|
* back after this long. `false` disables. Default: 1200.
|
||||||
*/
|
*/
|
||||||
peakHold?: number | false;
|
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. */
|
/** Peak indicator color. Defaults to the peak's zone color. */
|
||||||
peakColor?: string;
|
peakColor?: string;
|
||||||
/** Dedicated clip LED at the hot end of the meter. Default: true. */
|
/**
|
||||||
|
* 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;
|
showClip?: boolean;
|
||||||
/** Value at/above which the clip LED lights. Default: `max`. */
|
/** Value (in the display domain — dB for scale='db') at/above which it lights. Default: `max`. */
|
||||||
clipThreshold?: number;
|
clipThreshold?: number;
|
||||||
/**
|
/**
|
||||||
* Signal used for clip detection, when it differs from the displayed
|
* Signal(s) used for clip detection when different from the displayed
|
||||||
* level — e.g. show RMS on the bar but clip on sample peaks. Defaults
|
* level — e.g. show RMS on the bars but clip on sample peaks. Same shape
|
||||||
* to `value`.
|
* as `value`. Defaults to `value`.
|
||||||
*/
|
*/
|
||||||
clipValue?: number;
|
clipValue?: number | readonly number[];
|
||||||
/**
|
/**
|
||||||
* How long the clip LED stays lit in ms, or 'latch' to stay lit until
|
* How long the clip LED stays lit in ms, or 'latch' to stay lit until
|
||||||
* clicked. Default: 1500.
|
* clicked. Default: 1500.
|
||||||
*/
|
*/
|
||||||
clipHold?: number | 'latch';
|
clipHold?: number | 'latch';
|
||||||
clipColor?: string;
|
clipColor?: string;
|
||||||
/** Fired when the signal first crosses the clip threshold. */
|
/** Fired when any channel first crosses the clip threshold. */
|
||||||
onClip?: (value: number) => void;
|
onClip?: (value: number) => void;
|
||||||
/** Panel color behind the LEDs. */
|
/** Panel color behind the LEDs. */
|
||||||
faceColor?: string;
|
faceColor?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Show a seven-segment readout. Default: false. */
|
/** Show a seven-segment readout (of the hottest channel). Default: false. */
|
||||||
showValue?: boolean;
|
showValue?: boolean;
|
||||||
digits?: number;
|
digits?: number;
|
||||||
displayDecimals?: number;
|
displayDecimals?: number;
|
||||||
|
/** Unit suffix beside the readout, e.g. "dB". */
|
||||||
|
unit?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asArray = (v: number | readonly number[]): readonly number[] =>
|
||||||
|
Array.isArray(v) ? v : [v as number];
|
||||||
|
|
||||||
|
const DEFAULT_DB_TICKS = [0, -6, -12, -18, -24, -36, -48];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only LED level meter with peak hold — the display-side sibling of
|
* Read-only LED level meter with peak hold and a latching clip LED — pass an
|
||||||
* LEDFader for VU/output metering.
|
* array of values for stereo/multi-channel bars sharing one clip indicator.
|
||||||
|
* Optional PPM ballistics, a peak-decay tail, a dBFS scale and a windowed
|
||||||
|
* loudness readout (all opt-in).
|
||||||
*/
|
*/
|
||||||
export const Meter: React.FC<MeterProps> = ({
|
export const Meter: React.FC<MeterProps> = ({
|
||||||
value,
|
value,
|
||||||
min = 0,
|
min: minProp,
|
||||||
max = 100,
|
max: maxProp,
|
||||||
taper = linearTaper,
|
taper = linearTaper,
|
||||||
|
scale = 'linear',
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
length = 160,
|
length = 160,
|
||||||
|
fill = false,
|
||||||
|
disabled = false,
|
||||||
breadth = 16,
|
breadth = 16,
|
||||||
segments = 28,
|
segments = 28,
|
||||||
color = '#3df2ad',
|
color,
|
||||||
zones,
|
zones,
|
||||||
offOpacity = 0.1,
|
offOpacity = 0.1,
|
||||||
|
ballistics,
|
||||||
peakHold = 1200,
|
peakHold = 1200,
|
||||||
|
peakDecay,
|
||||||
peakColor,
|
peakColor,
|
||||||
|
integrated = false,
|
||||||
|
integrationWindow = 3000,
|
||||||
|
loudnessColor,
|
||||||
|
showScale,
|
||||||
|
referenceTicks,
|
||||||
showClip = true,
|
showClip = true,
|
||||||
clipThreshold,
|
clipThreshold,
|
||||||
clipValue,
|
clipValue,
|
||||||
clipHold = 1500,
|
clipHold = 1500,
|
||||||
clipColor = '#ff2b39',
|
clipColor = '#ff2b39',
|
||||||
onClip,
|
onClip,
|
||||||
faceColor = '#0b0d0e',
|
faceColor: faceColorProp,
|
||||||
label,
|
label,
|
||||||
showValue = false,
|
showValue = false,
|
||||||
digits = 4,
|
digits = 4,
|
||||||
displayDecimals = 1,
|
displayDecimals = 1,
|
||||||
|
unit,
|
||||||
labelColor,
|
labelColor,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...aria
|
...aria
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
|
const faceColor = faceColorProp ?? theme.ledWell ?? '#0b0d0e';
|
||||||
const vertical = orientation === 'vertical';
|
const vertical = orientation === 'vertical';
|
||||||
const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1);
|
const dbScale = scale === 'db';
|
||||||
|
const min = minProp ?? (dbScale ? -60 : 0);
|
||||||
|
const max = maxProp ?? (dbScale ? 0 : 100);
|
||||||
|
|
||||||
// Peak: track the highest recent level, fall back after `peakHold` ms.
|
const values = asArray(value);
|
||||||
const [peak, setPeak] = React.useState(n);
|
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));
|
||||||
|
|
||||||
|
// 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);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (peakHold === false) return;
|
if (animated || peakHold === false) return;
|
||||||
if (n >= peak) {
|
setLegacyPeaks(prev => {
|
||||||
setPeak(n);
|
const rose = targetNs.map((n, i) => n >= (prev[i] ?? 0));
|
||||||
return;
|
if (rose.every(Boolean) || prev.length !== targetNs.length)
|
||||||
}
|
return targetNs.map((n, i) => Math.max(n, prev[i] ?? 0));
|
||||||
const t = setTimeout(() => setPeak(n), peakHold);
|
return prev.map((p, i) => Math.max(p, targetNs[i] ?? 0));
|
||||||
|
});
|
||||||
|
const t = setTimeout(() => setLegacyPeaks(targetNs), peakHold);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [n, peak, peakHold]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [JSON.stringify(targetNs), peakHold, animated]);
|
||||||
|
const peaks = animated && engine ? engine.peaks : legacyPeaks;
|
||||||
|
|
||||||
// Clip: latch a dedicated LED when the raw (unclamped) signal reaches the
|
// Clip: latch when any channel's raw (unclamped) signal reaches threshold.
|
||||||
// threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode.
|
const clipSignals = (clipValue !== undefined ? asArray(clipValue) : values).map(v =>
|
||||||
|
dbScale ? ampToDb(v) : v,
|
||||||
|
);
|
||||||
|
const hottest = Math.max(...clipSignals);
|
||||||
const [clipped, setClipped] = React.useState(false);
|
const [clipped, setClipped] = React.useState(false);
|
||||||
const clipTimer = React.useRef<ReturnType<typeof setTimeout>>();
|
const clipTimer = React.useRef<ReturnType<typeof setTimeout>>();
|
||||||
const wasOver = React.useRef(false);
|
const wasOver = React.useRef(false);
|
||||||
|
|
@ -121,10 +216,9 @@ export const Meter: React.FC<MeterProps> = ({
|
||||||
onClipRef.current = onClip;
|
onClipRef.current = onClip;
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!showClip) return;
|
if (!showClip) return;
|
||||||
const signal = clipValue ?? value;
|
const over = hottest >= (clipThreshold ?? max) - 1e-9;
|
||||||
const over = signal >= (clipThreshold ?? max) - 1e-9;
|
|
||||||
if (over) {
|
if (over) {
|
||||||
if (!wasOver.current) onClipRef.current?.(signal);
|
if (!wasOver.current) onClipRef.current?.(hottest);
|
||||||
setClipped(true);
|
setClipped(true);
|
||||||
if (clipHold !== 'latch') {
|
if (clipHold !== 'latch') {
|
||||||
clearTimeout(clipTimer.current);
|
clearTimeout(clipTimer.current);
|
||||||
|
|
@ -132,51 +226,118 @@ export const Meter: React.FC<MeterProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wasOver.current = over;
|
wasOver.current = over;
|
||||||
}, [value, clipValue, showClip, clipThreshold, max, clipHold]);
|
}, [hottest, showClip, clipThreshold, max, clipHold]);
|
||||||
React.useEffect(() => () => clearTimeout(clipTimer.current), []);
|
React.useEffect(() => () => clearTimeout(clipTimer.current), []);
|
||||||
|
|
||||||
const pad = 4;
|
const zoneList: readonly LEDFaderZone[] = React.useMemo(() => {
|
||||||
const w = vertical ? breadth : length;
|
if (zones) return [...zones].sort((a, b) => a.upTo - b.upTo);
|
||||||
const h = vertical ? length : breadth;
|
if (color) return [{ upTo: 1, color }];
|
||||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(
|
return [
|
||||||
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
|
{ upTo: 0.72, color: theme.zoneGood },
|
||||||
[zones, color],
|
{ upTo: 0.9, color: theme.zoneWarn },
|
||||||
);
|
{ upTo: 1, color: theme.zoneHot },
|
||||||
|
];
|
||||||
|
}, [zones, color, theme.zoneGood, theme.zoneWarn, theme.zoneHot]);
|
||||||
const zoneFor = (t: number): string =>
|
const zoneFor = (t: number): string =>
|
||||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||||
|
|
||||||
const lit = Math.round(n * segments);
|
// Geometry — clamped so thin meters degrade gracefully instead of
|
||||||
const peakIdx =
|
// producing negative SVG rects. pad shrinks below breadth 14.
|
||||||
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1;
|
const pad = breadth < 14 ? 2 : 4;
|
||||||
// Reserve room at the hot end for the clip LED (7px LED + 3px gap).
|
const cross = Math.max(1, breadth - pad * 2);
|
||||||
|
const chGap = channels > 1 ? 3 : 0;
|
||||||
|
const along = length; // travel axis
|
||||||
|
const across = channels * breadth + (channels - 1) * chGap;
|
||||||
|
const w = vertical ? across : along;
|
||||||
|
const h = vertical ? along : across;
|
||||||
|
|
||||||
const clipLed = 7;
|
const clipLed = 7;
|
||||||
const clipSpan = showClip ? clipLed + 3 : 0;
|
const clipSpan = showClip ? clipLed + 3 : 0;
|
||||||
const slot = (length - pad * 2 - clipSpan) / segments;
|
const innerLen = along - pad * 2 - clipSpan;
|
||||||
|
const slot = Math.max(1, innerLen / segments);
|
||||||
const gap = Math.min(2.5, slot * 0.35);
|
const gap = Math.min(2.5, slot * 0.35);
|
||||||
const cross = breadth - pad * 2;
|
const segSize = Math.max(0.5, slot - gap);
|
||||||
|
|
||||||
const leds: React.ReactNode[] = [];
|
// 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 peakIdx =
|
||||||
|
peakHold !== false && (peaks[c] ?? 0) > 0
|
||||||
|
? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1)
|
||||||
|
: -1;
|
||||||
for (let i = 0; i < segments; i++) {
|
for (let i = 0; i < segments; i++) {
|
||||||
const zone = zoneFor((i + 1) / segments);
|
const zone = zoneFor((i + 1) / segments);
|
||||||
const isPeak = i === peakIdx && i >= lit;
|
const isPeak = i === peakIdx && i >= lit;
|
||||||
const on = i < lit || isPeak;
|
const on = i < lit || isPeak;
|
||||||
const fill = isPeak ? (peakColor ?? zone) : zone;
|
const barFill = isPeak ? (peakColor ?? zone) : zone;
|
||||||
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
|
const common = { rx: 1.2, fill: barFill, opacity: on ? 1 : offOpacity };
|
||||||
leds.push(
|
bars.push(
|
||||||
vertical ? (
|
vertical ? (
|
||||||
<rect
|
<rect
|
||||||
key={i}
|
key={`${c}-${i}`}
|
||||||
x={pad}
|
x={offset}
|
||||||
y={length - pad - (i + 1) * slot + gap / 2}
|
y={along - pad - (i + 1) * slot + gap / 2}
|
||||||
width={cross}
|
width={cross}
|
||||||
height={slot - gap}
|
height={segSize}
|
||||||
{...common}
|
{...common}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<rect key={i} x={pad + i * slot + gap / 2} y={pad} width={slot - gap} height={cross} {...common} />
|
<rect
|
||||||
|
key={`${c}-${i}`}
|
||||||
|
x={pad + i * slot + gap / 2}
|
||||||
|
y={offset}
|
||||||
|
width={segSize}
|
||||||
|
height={cross}
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -184,41 +345,80 @@ export const Meter: React.FC<MeterProps> = ({
|
||||||
role="meter"
|
role="meter"
|
||||||
aria-valuemin={min}
|
aria-valuemin={min}
|
||||||
aria-valuemax={max}
|
aria-valuemax={max}
|
||||||
aria-valuenow={clamp(value, min, max)}
|
aria-valuenow={hottestShown}
|
||||||
aria-label={aria['aria-label']}
|
aria-label={aria['aria-label']}
|
||||||
data-clipped={clipped ? '' : undefined}
|
data-clipped={clipped ? '' : undefined}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: fill ? 'flex' : 'inline-flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 5,
|
gap: 5,
|
||||||
width: w,
|
width: fill && !vertical ? '100%' : w,
|
||||||
|
height: fill && vertical ? '100%' : undefined,
|
||||||
|
opacity: disabled ? 0.45 : undefined,
|
||||||
...style,
|
...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 && (
|
{showValue && (
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||||
<SegmentDisplay
|
<SegmentDisplay
|
||||||
value={clamp(value, min, max)}
|
value={hottestShown}
|
||||||
digits={digits}
|
digits={digits}
|
||||||
decimals={displayDecimals}
|
decimals={displayDecimals}
|
||||||
height={11}
|
height={11}
|
||||||
color={zoneFor(n)}
|
color={readoutColor}
|
||||||
background="none"
|
background="none"
|
||||||
ghostOpacity={0.06}
|
ghostOpacity={0}
|
||||||
/>
|
/>
|
||||||
|
{unit && (
|
||||||
|
<span style={{ fontFamily: theme.fontMono, fontSize: 9, letterSpacing: '0.06em', color: theme.label, whiteSpace: 'nowrap' }}>
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
{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"
|
||||||
|
>
|
||||||
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
|
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
|
||||||
{leds}
|
{scaleLines}
|
||||||
|
{bars}
|
||||||
|
{loudnessLine}
|
||||||
{showClip && (
|
{showClip && (
|
||||||
<rect
|
<rect
|
||||||
data-part="clip"
|
data-part="clip"
|
||||||
x={vertical ? pad : length - pad - clipLed}
|
x={vertical ? pad : along - pad - clipLed}
|
||||||
y={vertical ? pad : pad}
|
y={vertical ? pad : pad}
|
||||||
width={vertical ? cross : clipLed}
|
width={vertical ? Math.max(1, across - pad * 2) : clipLed}
|
||||||
height={vertical ? clipLed : cross}
|
height={vertical ? clipLed : Math.max(1, across - pad * 2)}
|
||||||
rx={1.5}
|
rx={1.5}
|
||||||
fill={clipColor}
|
fill={clipColor}
|
||||||
opacity={clipped ? 1 : 0.14}
|
opacity={clipped ? 1 : 0.14}
|
||||||
|
|
|
||||||
124
packages/dreamknob/src/skins/MeterBridge.tsx
Normal file
124
packages/dreamknob/src/skins/MeterBridge.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import { Meter, type MeterProps } from './Meter';
|
||||||
|
|
||||||
|
export interface MeterBridgeChannel {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
/** Peak signal for clip detection, when different from `value`. */
|
||||||
|
clipValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeterBridgeProps
|
||||||
|
extends Omit<MeterProps, 'value' | 'clipValue' | 'label' | 'showValue' | 'aria-label'> {
|
||||||
|
channels: readonly MeterBridgeChannel[];
|
||||||
|
/** Numeric peak-hold text above each bar. Default: true. */
|
||||||
|
showPeakText?: boolean;
|
||||||
|
/** Decimals for the peak text. Default: 1. */
|
||||||
|
peakTextDecimals?: number;
|
||||||
|
/** Block caption under the bridge. */
|
||||||
|
label?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A labeled multi-strip meter block — n channels, per-channel peak text, one
|
||||||
|
* shared clip LED. The composition every mixer rebuilds, prebuilt.
|
||||||
|
*/
|
||||||
|
export const MeterBridge: React.FC<MeterBridgeProps> = ({
|
||||||
|
channels,
|
||||||
|
showPeakText = true,
|
||||||
|
peakTextDecimals = 1,
|
||||||
|
label,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
breadth = 16,
|
||||||
|
peakHold = 1200,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...meterProps
|
||||||
|
}) => {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const values = channels.map(c => c.value);
|
||||||
|
const clipValues = channels.map(c => c.clipValue ?? c.value);
|
||||||
|
const chGap = channels.length > 1 ? 3 : 0;
|
||||||
|
|
||||||
|
// Numeric peak-hold per channel for the text row.
|
||||||
|
const [peaks, setPeaks] = React.useState<readonly number[]>(values);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (peakHold === false) return;
|
||||||
|
setPeaks(prev =>
|
||||||
|
prev.length === values.length ? values.map((v, i) => Math.max(v, prev[i] ?? -Infinity)) : values,
|
||||||
|
);
|
||||||
|
const t = setTimeout(() => setPeaks(values), peakHold);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [JSON.stringify(values), peakHold]);
|
||||||
|
|
||||||
|
const cell: React.CSSProperties = {
|
||||||
|
width: breadth,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'visible',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
role="group"
|
||||||
|
aria-label={meterProps['aria-label' as never] ?? label}
|
||||||
|
style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4, ...style }}
|
||||||
|
>
|
||||||
|
{showPeakText && (
|
||||||
|
<div style={{ display: 'flex', gap: chGap }}>
|
||||||
|
{peaks.map((p, i) => (
|
||||||
|
<span key={i} style={{ ...cell, fontSize: 9, color: theme.label }}>
|
||||||
|
{Math.max(p, min) <= min ? '—' : Math.min(p, max).toFixed(peakTextDecimals)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Meter
|
||||||
|
{...meterProps}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
breadth={breadth}
|
||||||
|
peakHold={peakHold}
|
||||||
|
value={values}
|
||||||
|
clipValue={clipValues}
|
||||||
|
aria-label={undefined}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: chGap }}>
|
||||||
|
{channels.map(c => (
|
||||||
|
<span
|
||||||
|
key={c.label}
|
||||||
|
style={{
|
||||||
|
...cell,
|
||||||
|
fontSize: 9.5,
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: theme.label,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: theme.label,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -23,6 +23,8 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
|
||||||
labelColor,
|
labelColor,
|
||||||
arcFrom = 'min',
|
arcFrom = 'min',
|
||||||
label,
|
label,
|
||||||
|
sublabel,
|
||||||
|
labelSize,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
|
|
@ -36,7 +38,7 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
const glowId = `${id}-glow`;
|
const glowId = `${id}-glow`;
|
||||||
return (
|
return (
|
||||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
|
||||||
<defs>
|
<defs>
|
||||||
<filter
|
<filter
|
||||||
id={glowId}
|
id={glowId}
|
||||||
|
|
@ -85,7 +87,8 @@ export const NeonKnob = React.forwardRef<HTMLDivElement, NeonKnobProps>(function
|
||||||
color={labelColor ?? theme.label}
|
color={labelColor ?? theme.label}
|
||||||
fontFamily={theme.fontUI}
|
fontFamily={theme.fontUI}
|
||||||
dy={size * 0.45}
|
dy={size * 0.45}
|
||||||
fontSize={size * 0.1}
|
fontSize={labelSize ?? size * 0.1}
|
||||||
|
sublabel={sublabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
|
|
|
||||||
60
packages/dreamknob/src/skins/PanKnob.tsx
Normal file
60
packages/dreamknob/src/skins/PanKnob.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
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 −50…50
|
||||||
|
* range centered at 0; pass `min`/`max` for other conventions (e.g. −1…1).
|
||||||
|
*/
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,17 +1,37 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useKnobTheme } from '../core/theme';
|
import { panelButtonChrome, useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
export interface PushButtonProps {
|
/** 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'> {
|
||||||
/** Controlled pressed state. Pair with `onChange`. */
|
/** Controlled pressed state. Pair with `onChange`. */
|
||||||
pressed?: boolean;
|
pressed?: boolean;
|
||||||
defaultPressed?: boolean;
|
defaultPressed?: boolean;
|
||||||
onChange?: (pressed: boolean) => void;
|
onChange?: (pressed: boolean) => void;
|
||||||
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
|
/**
|
||||||
mode?: 'toggle' | 'momentary';
|
* '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;
|
||||||
/** LED / active color. */
|
/** LED / active color. */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Show the LED strip. Default: true. */
|
/**
|
||||||
led?: boolean;
|
* 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?: boolean | 'strip' | 'dot';
|
||||||
/** Button height in px. Default: 36. */
|
/** Button height in px. Default: 36. */
|
||||||
size?: number;
|
size?: number;
|
||||||
/** Explicit width; defaults to content. */
|
/** Explicit width; defaults to content. */
|
||||||
|
|
@ -19,11 +39,12 @@ export interface PushButtonProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Render a hidden form input carrying "on"/"off". */
|
/** Render a hidden form input carrying "on"/"off". */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Icon rendered before the caption (e.g. a lucide glyph), tinted to the LED. */
|
||||||
|
leadingIcon?: React.ReactNode;
|
||||||
/** Caption, e.g. "MUTE". */
|
/** Caption, e.g. "MUTE". */
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
'aria-label'?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Studio panel button with an LED strip — mute/solo/bypass in matching style. */
|
/** Studio panel button with an LED strip — mute/solo/bypass in matching style. */
|
||||||
|
|
@ -33,35 +54,69 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
pressed,
|
pressed,
|
||||||
defaultPressed = false,
|
defaultPressed = false,
|
||||||
onChange,
|
onChange,
|
||||||
|
onClick,
|
||||||
mode = 'toggle',
|
mode = 'toggle',
|
||||||
color,
|
color,
|
||||||
led = true,
|
led: ledProp,
|
||||||
size = 36,
|
size = 36,
|
||||||
width,
|
width,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
name,
|
name,
|
||||||
|
leadingIcon,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...aria
|
...rest
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const theme = useKnobTheme();
|
const theme = useKnobTheme();
|
||||||
const accent = color ?? theme.accent ?? '#4cc2ff';
|
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 isControlled = pressed !== undefined;
|
||||||
const [internal, setInternal] = React.useState(defaultPressed);
|
const [internal, setInternal] = React.useState(defaultPressed);
|
||||||
const [focusVisible, setFocusVisible] = React.useState(false);
|
const [focusVisible, setFocusVisible] = React.useState(false);
|
||||||
const on = isControlled ? (pressed as boolean) : internal;
|
// 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 set = (next: boolean) => {
|
const set = (next: boolean) => {
|
||||||
if (next === on) return;
|
if (next === latched) return;
|
||||||
if (!isControlled) setInternal(next);
|
if (!isControlled) setInternal(next);
|
||||||
onChange?.(next);
|
onChange?.(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fire = () => {
|
||||||
|
onClick?.();
|
||||||
|
setFlash(true);
|
||||||
|
clearTimeout(flashTimer.current);
|
||||||
|
flashTimer.current = setTimeout(() => setFlash(false), 160);
|
||||||
|
};
|
||||||
|
|
||||||
const momentaryProps =
|
const momentaryProps =
|
||||||
mode === 'momentary'
|
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'
|
||||||
? {
|
? {
|
||||||
onPointerDown: (e: React.PointerEvent) => {
|
onPointerDown: (e: React.PointerEvent) => {
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
|
@ -76,27 +131,40 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
if (e.key === ' ' || e.key === 'Enter') set(false);
|
if (e.key === ' ' || e.key === 'Enter') set(false);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: { onClick: () => set(!on) };
|
: { onClick: () => set(!latched) };
|
||||||
|
|
||||||
return (
|
// Merge our own handlers with any consumer/Radix-injected ones of the same
|
||||||
<button
|
// name (from {...rest}), so the button works as a Radix `asChild` trigger:
|
||||||
ref={ref}
|
// the injected onPointerDown/onKeyDown/data-state/aria-* all reach the DOM.
|
||||||
type="button"
|
const restHandlers = rest as Record<string, ((e: unknown) => void) | undefined>;
|
||||||
disabled={disabled}
|
const composedHandlers = Object.fromEntries(
|
||||||
aria-pressed={on}
|
Object.entries(momentaryProps).map(([k, fn]) => [
|
||||||
aria-label={aria['aria-label']}
|
k,
|
||||||
data-pressed={on ? '' : undefined}
|
chain(restHandlers[k], fn as (e: unknown) => void),
|
||||||
data-focus-visible={focusVisible ? '' : undefined}
|
]),
|
||||||
className={className}
|
);
|
||||||
onFocus={e => {
|
const handleFocus = chain(restHandlers.onFocus, (e: React.FocusEvent<HTMLButtonElement>) => {
|
||||||
try {
|
try {
|
||||||
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||||||
} catch {
|
} catch {
|
||||||
setFocusVisible(true);
|
setFocusVisible(true);
|
||||||
}
|
}
|
||||||
}}
|
});
|
||||||
onBlur={() => setFocusVisible(false)}
|
const handleBlur = chain(restHandlers.onBlur, () => setFocusVisible(false));
|
||||||
{...momentaryProps}
|
|
||||||
|
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}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
|
|
@ -109,16 +177,12 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
minWidth: size * 1.4,
|
minWidth: size * 1.4,
|
||||||
padding: '0 12px',
|
padding: '0 12px',
|
||||||
borderRadius: 7,
|
borderRadius: 7,
|
||||||
border: '1px solid rgba(0,0,0,0.7)',
|
border: chrome.border,
|
||||||
background: on
|
background: chrome.background,
|
||||||
? 'linear-gradient(180deg, #17181d, #232429)'
|
|
||||||
: 'linear-gradient(180deg, #35363d, #1d1e24)',
|
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
boxShadow: `${
|
boxShadow: `${chrome.boxShadow}${
|
||||||
on
|
focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''
|
||||||
? '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,
|
color: on ? theme.text : theme.label,
|
||||||
fontFamily: theme.fontUI,
|
fontFamily: theme.fontUI,
|
||||||
fontSize: Math.max(10, size * 0.28),
|
fontSize: Math.max(10, size * 0.28),
|
||||||
|
|
@ -134,22 +198,72 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{led && (
|
{(led === true || led === 'strip') && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
width: '55%',
|
width: '55%',
|
||||||
height: Math.max(3, size * 0.09),
|
height: Math.max(3, size * 0.09),
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
background: on ? accent : 'rgba(255,255,255,0.09)',
|
background: ledOn ? accent : ledOff,
|
||||||
boxShadow: on ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
|
boxShadow: ledOn ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
|
||||||
transition: 'background 80ms, box-shadow 80ms',
|
transition: 'background 80ms, box-shadow 80ms',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{led === 'dot' && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: Math.max(3, size * 0.1),
|
||||||
|
right: Math.max(3, size * 0.1),
|
||||||
|
width: Math.max(4, size * 0.14),
|
||||||
|
height: Math.max(4, size * 0.14),
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: ledOn ? accent : ledOff,
|
||||||
|
boxShadow: ledOn ? `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}
|
{children}
|
||||||
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
|
</span>
|
||||||
|
)}
|
||||||
|
{name && <input type="hidden" name={name} value={latched ? 'on' : 'off'} readOnly />}
|
||||||
</button>
|
</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} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
|
||||||
labelColor,
|
labelColor,
|
||||||
arcFrom = 'min',
|
arcFrom = 'min',
|
||||||
label,
|
label,
|
||||||
|
sublabel,
|
||||||
|
labelSize,
|
||||||
showValue = false,
|
showValue = false,
|
||||||
unit,
|
unit,
|
||||||
format,
|
format,
|
||||||
|
|
@ -42,7 +44,7 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
|
||||||
const bodyR = size / 2 - size * 0.17;
|
const bodyR = size / 2 - size * 0.17;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core}>
|
<Knob ref={ref} size={size} className={className} style={style} focusRing={focusRing} {...core} getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id={bodyId} cx="0.38" cy="0.3" r="1">
|
<radialGradient id={bodyId} cx="0.38" cy="0.3" r="1">
|
||||||
<stop offset="0" stopColor="#3a3b41" />
|
<stop offset="0" stopColor="#3a3b41" />
|
||||||
|
|
@ -103,7 +105,8 @@ export const RubberKnob = React.forwardRef<HTMLDivElement, RubberKnobProps>(func
|
||||||
color={labelColor ?? theme.label}
|
color={labelColor ?? theme.label}
|
||||||
fontFamily={theme.fontUI}
|
fontFamily={theme.fontUI}
|
||||||
dy={size * 0.44}
|
dy={size * 0.44}
|
||||||
fontSize={size * 0.1}
|
fontSize={labelSize ?? size * 0.1}
|
||||||
|
sublabel={sublabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
|
|
|
||||||
294
packages/dreamknob/src/skins/ScrubField.tsx
Normal file
294
packages/dreamknob/src/skins/ScrubField.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
import type { ChangeMeta, ChangeSource } from '../core/types';
|
||||||
|
import { defaultParseValue } from '../components/ValueInput';
|
||||||
|
|
||||||
|
export interface ScrubFieldProps {
|
||||||
|
/** Controlled value. Pair with `onChange`. */
|
||||||
|
value?: number;
|
||||||
|
defaultValue?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
/** Increment per scrub pixel-step / wheel notch / arrow key. Default: 1. */
|
||||||
|
step?: number;
|
||||||
|
/** Decimal places of emitted values. Defaults to the precision of `step`. */
|
||||||
|
decimals?: number;
|
||||||
|
onChange?: (value: number, meta: ChangeMeta) => void;
|
||||||
|
onChangeStart?: (value: number, meta: ChangeMeta) => void;
|
||||||
|
onChangeEnd?: (value: number, meta: ChangeMeta) => void;
|
||||||
|
/** The scrub handle text — drag it horizontally to adjust. */
|
||||||
|
label?: string;
|
||||||
|
/** Unit suffix shown after the field, e.g. "px". */
|
||||||
|
unit?: string;
|
||||||
|
/** Horizontal pixels per step while scrubbing. Default: 2. */
|
||||||
|
scrubSensitivity?: number;
|
||||||
|
/** Step multiplier while Shift is held (scrub + wheel). Default: 0.1. */
|
||||||
|
fineMultiplier?: number;
|
||||||
|
/** Custom parser for typed input. Default accepts "1.2k", commas, units. */
|
||||||
|
parseValue?: (text: string) => number | null;
|
||||||
|
/** Input width in ch. Default: 6. */
|
||||||
|
widthCh?: number;
|
||||||
|
accentColor?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Render a hidden form input carrying the committed value. */
|
||||||
|
name?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compact numeric field with After-Effects-style label scrubbing: drag the
|
||||||
|
* label for relative adjustment, click the field to type (draft held until
|
||||||
|
* Enter/blur, Escape reverts). The spinbox replacement for values where
|
||||||
|
* typing dominates but scrubbing should still work.
|
||||||
|
*/
|
||||||
|
export const ScrubField = React.forwardRef<HTMLInputElement, ScrubFieldProps>(
|
||||||
|
function ScrubField(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
min = -Infinity,
|
||||||
|
max = Infinity,
|
||||||
|
step = 1,
|
||||||
|
decimals,
|
||||||
|
onChange,
|
||||||
|
onChangeStart,
|
||||||
|
onChangeEnd,
|
||||||
|
label,
|
||||||
|
unit,
|
||||||
|
scrubSensitivity = 2,
|
||||||
|
fineMultiplier = 0.1,
|
||||||
|
parseValue,
|
||||||
|
widthCh = 6,
|
||||||
|
accentColor,
|
||||||
|
disabled = false,
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...aria
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const accent = accentColor ?? theme.accent ?? '#4cc2ff';
|
||||||
|
const dec = decimals ?? (step > 0 ? decimalsFromStep(step) : 3);
|
||||||
|
|
||||||
|
const constrain = React.useCallback(
|
||||||
|
(raw: number) => {
|
||||||
|
let v = clamp(raw, min, max);
|
||||||
|
if (step > 0 && Number.isFinite(min)) v = clamp(snapToStep(v, step, min), min, max);
|
||||||
|
else if (step > 0) v = Math.round(v / step) * step;
|
||||||
|
return roundTo(v, dec);
|
||||||
|
},
|
||||||
|
[min, max, step, dec],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const [internal, setInternal] = React.useState(() => constrain(defaultValue ?? (Number.isFinite(min) ? min : 0)));
|
||||||
|
const committed = constrain(isControlled ? (value as number) : internal);
|
||||||
|
const committedRef = React.useRef(committed);
|
||||||
|
committedRef.current = committed;
|
||||||
|
|
||||||
|
const emit = React.useCallback(
|
||||||
|
(raw: number, source: ChangeSource) => {
|
||||||
|
const next = constrain(raw);
|
||||||
|
if (next === committedRef.current) return;
|
||||||
|
if (!isControlled) {
|
||||||
|
committedRef.current = next;
|
||||||
|
setInternal(next);
|
||||||
|
}
|
||||||
|
onChange?.(next, { source });
|
||||||
|
},
|
||||||
|
[constrain, isControlled, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draft typing: the input owns its text between focus and commit.
|
||||||
|
const [draft, setDraft] = React.useState<string | null>(null);
|
||||||
|
// Enter/Escape settle the draft themselves, then blur — the blur handler
|
||||||
|
// must not commit again off its stale closure.
|
||||||
|
const skipBlurCommit = React.useRef(false);
|
||||||
|
const trim = (v: number) => {
|
||||||
|
const s = v.toFixed(dec);
|
||||||
|
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
|
||||||
|
};
|
||||||
|
const shown = draft ?? trim(committed);
|
||||||
|
|
||||||
|
const commitDraft = (text: string) => {
|
||||||
|
const parsed = (parseValue ?? defaultParseValue)(text);
|
||||||
|
if (parsed !== null && Number.isFinite(parsed)) {
|
||||||
|
const target = constrain(parsed);
|
||||||
|
onChangeStart?.(committedRef.current, { source: 'edit' });
|
||||||
|
emit(target, 'edit');
|
||||||
|
onChangeEnd?.(target, { source: 'edit' });
|
||||||
|
}
|
||||||
|
setDraft(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Label scrubbing.
|
||||||
|
const scrub = React.useRef<{ pointerId: number; lastX: number; acc: number } | null>(null);
|
||||||
|
const [scrubbing, setScrubbing] = React.useState(false);
|
||||||
|
const onScrubDown = (e: React.PointerEvent<HTMLSpanElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
scrub.current = { pointerId: e.pointerId, lastX: e.clientX, acc: 0 };
|
||||||
|
setScrubbing(true);
|
||||||
|
onChangeStart?.(committedRef.current, { source: 'drag' });
|
||||||
|
};
|
||||||
|
const onScrubMove = (e: React.PointerEvent<HTMLSpanElement>) => {
|
||||||
|
const s = scrub.current;
|
||||||
|
if (!s || e.pointerId !== s.pointerId) return;
|
||||||
|
s.acc += e.clientX - s.lastX;
|
||||||
|
s.lastX = e.clientX;
|
||||||
|
const stepsMoved = Math.trunc(s.acc / scrubSensitivity);
|
||||||
|
if (stepsMoved !== 0) {
|
||||||
|
s.acc -= stepsMoved * scrubSensitivity;
|
||||||
|
const mult = e.shiftKey ? fineMultiplier : 1;
|
||||||
|
emit(committedRef.current + stepsMoved * step * mult, 'drag');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onScrubUp = (e: React.PointerEvent<HTMLSpanElement>) => {
|
||||||
|
if (!scrub.current || e.pointerId !== scrub.current.pointerId) return;
|
||||||
|
scrub.current = null;
|
||||||
|
setScrubbing(false);
|
||||||
|
onChangeEnd?.(committedRef.current, { source: 'drag' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wheel over the field (non-passive so the page doesn't scroll).
|
||||||
|
const wrapRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const wheelState = React.useRef({ active: false, timer: undefined as ReturnType<typeof setTimeout> | undefined });
|
||||||
|
const emitRef = React.useRef(emit);
|
||||||
|
emitRef.current = emit;
|
||||||
|
const cbRef = React.useRef({ onChangeStart, onChangeEnd });
|
||||||
|
cbRef.current = { onChangeStart, onChangeEnd };
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (!el || disabled) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!wheelState.current.active) {
|
||||||
|
wheelState.current.active = true;
|
||||||
|
cbRef.current.onChangeStart?.(committedRef.current, { source: 'wheel' });
|
||||||
|
}
|
||||||
|
const dir = e.deltaY < 0 ? 1 : -1;
|
||||||
|
const mult = e.shiftKey ? fineMultiplier : 1;
|
||||||
|
emitRef.current(committedRef.current + dir * step * mult, 'wheel');
|
||||||
|
clearTimeout(wheelState.current.timer);
|
||||||
|
wheelState.current.timer = setTimeout(() => {
|
||||||
|
wheelState.current.active = false;
|
||||||
|
cbRef.current.onChangeEnd?.(committedRef.current, { source: 'wheel' });
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('wheel', onWheel);
|
||||||
|
clearTimeout(wheelState.current.timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [disabled, step, fineMultiplier]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapRef}
|
||||||
|
className={className}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
|
data-scrubbing={scrubbing ? '' : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
onPointerDown={onScrubDown}
|
||||||
|
onPointerMove={onScrubMove}
|
||||||
|
onPointerUp={onScrubUp}
|
||||||
|
onPointerCancel={onScrubUp}
|
||||||
|
title="Drag to adjust"
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: scrubbing ? accent : theme.label,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'ew-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
transition: 'color 80ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
role="spinbutton"
|
||||||
|
aria-valuemin={Number.isFinite(min) ? min : undefined}
|
||||||
|
aria-valuemax={Number.isFinite(max) ? max : undefined}
|
||||||
|
aria-valuenow={committed}
|
||||||
|
aria-label={aria['aria-label'] ?? label}
|
||||||
|
disabled={disabled}
|
||||||
|
value={shown}
|
||||||
|
onFocus={e => {
|
||||||
|
setDraft(trim(committed));
|
||||||
|
e.currentTarget.select();
|
||||||
|
}}
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
onBlur={e => {
|
||||||
|
if (skipBlurCommit.current) {
|
||||||
|
skipBlurCommit.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft !== null) commitDraft(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
commitDraft(e.currentTarget.value);
|
||||||
|
skipBlurCommit.current = true;
|
||||||
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setDraft(null);
|
||||||
|
skipBlurCommit.current = true;
|
||||||
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const dir = e.key === 'ArrowUp' ? 1 : -1;
|
||||||
|
const mult = e.shiftKey ? 10 : 1;
|
||||||
|
emit(committedRef.current + dir * step * mult, 'keyboard');
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: `${widthCh}ch`,
|
||||||
|
background: 'rgba(0,0,0,0.35)',
|
||||||
|
border: `1px solid ${scrubbing ? accent : 'rgba(255,255,255,0.14)'}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '4px 7px',
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: theme.text,
|
||||||
|
textAlign: 'right',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 80ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{unit && (
|
||||||
|
<span style={{ fontFamily: theme.fontMono, fontSize: 11, color: theme.label, whiteSpace: 'nowrap' }}>
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{name && <input type="hidden" name={name} value={committed} readOnly />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
176
packages/dreamknob/src/skins/SegmentSwitch.tsx
Normal file
176
packages/dreamknob/src/skins/SegmentSwitch.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
|
export interface SegmentSwitchProps {
|
||||||
|
/** Position labels, e.g. ['LINEAR', 'RADIAL', 'CONIC']. */
|
||||||
|
options: readonly string[];
|
||||||
|
/** Controlled selected index. Pair with `onChange`. */
|
||||||
|
value?: number;
|
||||||
|
defaultValue?: number;
|
||||||
|
onChange?: (index: number) => void;
|
||||||
|
/** Accent for the active cell's LED tick and text. */
|
||||||
|
color?: string;
|
||||||
|
/** Control height in px. Default: 30. */
|
||||||
|
size?: number;
|
||||||
|
/** Show the LED tick on the active cell. Default: true. */
|
||||||
|
led?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Render a hidden form input carrying the selected option text. */
|
||||||
|
name?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardware-styled segmented selector — the horizontal sibling of
|
||||||
|
* SteppedKnob. Renders a radiogroup; arrow keys move the selection.
|
||||||
|
*/
|
||||||
|
export const SegmentSwitch = React.forwardRef<HTMLDivElement, SegmentSwitchProps>(
|
||||||
|
function SegmentSwitch(
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
defaultValue = 0,
|
||||||
|
onChange,
|
||||||
|
color,
|
||||||
|
size = 30,
|
||||||
|
led = true,
|
||||||
|
disabled = false,
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...aria
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const accent = color ?? theme.accent ?? '#4cc2ff';
|
||||||
|
const 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));
|
||||||
|
const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
||||||
|
const select = (i: number, focus = false) => {
|
||||||
|
const next = Math.max(0, Math.min(options.length - 1, i));
|
||||||
|
if (focus) btnRefs.current[next]?.focus();
|
||||||
|
if (next === active) return;
|
||||||
|
if (!isControlled) setInternal(next);
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
select(active + 1, true);
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
select(active - 1, true);
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
select(0, true);
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
select(options.length - 1, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={aria['aria-label']}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
|
className={className}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
height: size,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 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)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((opt, i) => {
|
||||||
|
const isActive = i === active;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
ref={el => {
|
||||||
|
btnRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
disabled={disabled}
|
||||||
|
data-active={isActive ? '' : undefined}
|
||||||
|
onClick={() => select(i)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
padding: '0 12px',
|
||||||
|
minWidth: size * 1.6,
|
||||||
|
border: 'none',
|
||||||
|
borderRight:
|
||||||
|
i < options.length - 1
|
||||||
|
? `1px solid ${light ? 'rgba(0,0,0,0.1)' : '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))'
|
||||||
|
: 'transparent',
|
||||||
|
boxShadow: isActive
|
||||||
|
? light
|
||||||
|
? '0 1px 2px rgba(0,0,0,0.16)'
|
||||||
|
: 'inset 0 2px 4px rgba(0,0,0,0.55)'
|
||||||
|
: undefined,
|
||||||
|
color: isActive ? theme.text : theme.label,
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: Math.max(10, size * 0.34),
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background 80ms, color 80ms',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{led && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: '55%',
|
||||||
|
height: Math.max(2, size * 0.08),
|
||||||
|
borderRadius: 2,
|
||||||
|
background: isActive ? accent : light ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.08)',
|
||||||
|
boxShadow: isActive ? `0 0 ${size * 0.2}px ${accent}` : undefined,
|
||||||
|
transition: 'background 80ms, box-shadow 80ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{name && <input type="hidden" name={name} value={options[active]} readOnly />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -29,6 +29,8 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
|
||||||
textColor,
|
textColor,
|
||||||
labelColor,
|
labelColor,
|
||||||
label,
|
label,
|
||||||
|
sublabel,
|
||||||
|
labelSize,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
format,
|
format,
|
||||||
focusRing,
|
focusRing,
|
||||||
|
|
@ -53,6 +55,7 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
steps={detents}
|
steps={detents}
|
||||||
|
getAriaValueText={core.getAriaValueText ?? (positions ? (v: number) => positions[Math.round(((v - min) / (max - min || 1)) * (detents - 1))] ?? String(v) : format ? (v: number) => format(v) : undefined)}
|
||||||
>
|
>
|
||||||
{ctx => {
|
{ctx => {
|
||||||
const index = Math.round(ctx.normalized * (detents - 1));
|
const index = Math.round(ctx.normalized * (detents - 1));
|
||||||
|
|
@ -105,7 +108,8 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
|
||||||
color={labelColor ?? theme.label}
|
color={labelColor ?? theme.label}
|
||||||
fontFamily={theme.fontUI}
|
fontFamily={theme.fontUI}
|
||||||
dy={size * 0.45}
|
dy={size * 0.45}
|
||||||
fontSize={size * 0.095}
|
fontSize={labelSize ?? size * 0.095}
|
||||||
|
sublabel={sublabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
|
|
|
||||||
195
packages/dreamknob/src/skins/ToggleSwitch.tsx
Normal file
195
packages/dreamknob/src/skins/ToggleSwitch.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useKnobTheme } from '../core/theme';
|
||||||
|
|
||||||
|
export interface ToggleSwitchProps {
|
||||||
|
/** Controlled on-state. Pair with `onChange`. */
|
||||||
|
on?: boolean;
|
||||||
|
defaultOn?: boolean;
|
||||||
|
onChange?: (on: boolean) => void;
|
||||||
|
/** Accent color when on. Defaults to the theme accent. */
|
||||||
|
color?: string;
|
||||||
|
/** Track height in px (the knob is sized from it). Default: 22. */
|
||||||
|
size?: number;
|
||||||
|
/** Caption beside the switch. */
|
||||||
|
label?: string;
|
||||||
|
labelPosition?: 'right' | 'left';
|
||||||
|
/** Tiny OFF/ON legend printed on the track. Default: false. */
|
||||||
|
showStateLabel?: boolean;
|
||||||
|
/** Text for the on/off legend when `showStateLabel`. Default: 'ON'/'OFF'. */
|
||||||
|
onLabel?: string;
|
||||||
|
offLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Render a hidden form input carrying "on"/"off". */
|
||||||
|
name?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rocker/slider switch — the most literal boolean idiom. Renders
|
||||||
|
* `role="switch"`; the knob slides between OFF and ON. Space/Enter toggles,
|
||||||
|
* ←/→ set off/on.
|
||||||
|
*/
|
||||||
|
export const ToggleSwitch = React.forwardRef<HTMLButtonElement, ToggleSwitchProps>(
|
||||||
|
function ToggleSwitch(
|
||||||
|
{
|
||||||
|
on,
|
||||||
|
defaultOn = false,
|
||||||
|
onChange,
|
||||||
|
color,
|
||||||
|
size = 22,
|
||||||
|
label,
|
||||||
|
labelPosition = 'right',
|
||||||
|
showStateLabel = false,
|
||||||
|
onLabel = 'ON',
|
||||||
|
offLabel = 'OFF',
|
||||||
|
disabled = false,
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...aria
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const theme = useKnobTheme();
|
||||||
|
const accent = color ?? theme.accent ?? '#4cc2ff';
|
||||||
|
const isControlled = on !== undefined;
|
||||||
|
const [internal, setInternal] = React.useState(defaultOn);
|
||||||
|
const [focusVisible, setFocusVisible] = React.useState(false);
|
||||||
|
const lit = isControlled ? (on as boolean) : internal;
|
||||||
|
|
||||||
|
const set = (next: boolean) => {
|
||||||
|
if (next === lit) return;
|
||||||
|
if (!isControlled) setInternal(next);
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackW = size * 1.85;
|
||||||
|
const trackH = size;
|
||||||
|
const knobD = size - 6;
|
||||||
|
const pad = 3;
|
||||||
|
const knobX = lit ? trackW - knobD - pad : pad;
|
||||||
|
|
||||||
|
const legend = (side: 'on' | 'off') => (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
[side === 'on' ? 'left' : 'right']: knobD + pad + 2,
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: Math.max(7, size * 0.34),
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: side === 'on' ? 'rgba(0,0,0,0.72)' : 'rgba(255,255,255,0.4)',
|
||||||
|
opacity: (side === 'on') === lit ? 1 : 0,
|
||||||
|
transition: 'opacity 120ms',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{side === 'on' ? onLabel : offLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={lit}
|
||||||
|
aria-label={aria['aria-label'] ?? label}
|
||||||
|
disabled={disabled}
|
||||||
|
data-on={lit ? '' : undefined}
|
||||||
|
data-focus-visible={focusVisible ? '' : undefined}
|
||||||
|
className={className}
|
||||||
|
onClick={() => set(!lit)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
set(false);
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
set(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={e => {
|
||||||
|
try {
|
||||||
|
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||||||
|
} catch {
|
||||||
|
setFocusVisible(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setFocusVisible(false)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: labelPosition === 'left' ? 'row-reverse' : 'row',
|
||||||
|
gap: Math.max(6, size * 0.4),
|
||||||
|
padding: 3,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
outline: 'none',
|
||||||
|
boxShadow: focusVisible ? `0 0 0 2px ${theme.focusRing}` : undefined,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
flex: 'none',
|
||||||
|
width: trackW,
|
||||||
|
height: trackH,
|
||||||
|
borderRadius: trackH / 2,
|
||||||
|
background: lit ? accent : 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid rgba(0,0,0,0.55)',
|
||||||
|
boxShadow: lit
|
||||||
|
? `inset 0 0 ${size * 0.3}px rgba(0,0,0,0.25)`
|
||||||
|
: 'inset 0 1px 3px rgba(0,0,0,0.6)',
|
||||||
|
transition: 'background 120ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showStateLabel && legend('on')}
|
||||||
|
{showStateLabel && legend('off')}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: pad,
|
||||||
|
left: knobX,
|
||||||
|
width: knobD,
|
||||||
|
height: knobD,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(180deg, #f4f5f7, #cfd1d6)',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||||
|
transition: 'left 130ms cubic-bezier(0.3, 0.7, 0.4, 1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontUI,
|
||||||
|
fontSize: Math.max(11, size * 0.55),
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: lit ? theme.text : theme.label,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
transition: 'color 120ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{name && <input type="hidden" name={name} value={lit ? 'on' : 'off'} readOnly />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
180
packages/dreamknob/src/skins/TransportButton.tsx
Normal file
180
packages/dreamknob/src/skins/TransportButton.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -103,6 +103,8 @@ export const VintageKnob = React.forwardRef<HTMLDivElement, VintageKnobProps>(fu
|
||||||
scaleLabels,
|
scaleLabels,
|
||||||
indicatorColor,
|
indicatorColor,
|
||||||
label,
|
label,
|
||||||
|
sublabel,
|
||||||
|
labelSize,
|
||||||
focusRing,
|
focusRing,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
|
@ -132,7 +134,8 @@ export const VintageKnob = React.forwardRef<HTMLDivElement, VintageKnobProps>(fu
|
||||||
color={labelColor ?? theme.label}
|
color={labelColor ?? theme.label}
|
||||||
fontFamily={theme.fontUI}
|
fontFamily={theme.fontUI}
|
||||||
dy={size * 0.46}
|
dy={size * 0.46}
|
||||||
fontSize={size * 0.095}
|
fontSize={labelSize ?? size * 0.095}
|
||||||
|
sublabel={sublabel}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</KnobLabel>
|
</KnobLabel>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
|
import { applyDetents, clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
|
||||||
import { useKnobTheme } from '../core/theme';
|
import { useKnobTheme } from '../core/theme';
|
||||||
import type { ChangeMeta } from '../core/types';
|
import type { ChangeMeta } from '../core/types';
|
||||||
|
|
||||||
|
|
@ -8,6 +8,14 @@ export interface XYPadAxis {
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
defaultValue?: number;
|
defaultValue?: number;
|
||||||
|
/**
|
||||||
|
* Flip the axis direction. Y is bottom-up by default (audio convention);
|
||||||
|
* `invert: true` makes it top-down (screen convention). X is left-right;
|
||||||
|
* inverted is right-left.
|
||||||
|
*/
|
||||||
|
invert?: boolean;
|
||||||
|
/** Magnetic snap values for this axis while dragging (like knob detents). */
|
||||||
|
detents?: readonly number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XYPadValue {
|
export interface XYPadValue {
|
||||||
|
|
@ -32,6 +40,8 @@ export interface XYPadProps {
|
||||||
faceColor?: string;
|
faceColor?: string;
|
||||||
/** Grid divisions per axis (0 disables). Default: 4. */
|
/** Grid divisions per axis (0 disables). Default: 4. */
|
||||||
grid?: number;
|
grid?: number;
|
||||||
|
/** Detent capture radius as a fraction of travel. Default: 0.025. */
|
||||||
|
detentSize?: number;
|
||||||
/** Show the "x · y" readout above the pad. Default: true. */
|
/** Show the "x · y" readout above the pad. Default: true. */
|
||||||
showValue?: boolean;
|
showValue?: boolean;
|
||||||
formatX?: (v: number) => string;
|
formatX?: (v: number) => string;
|
||||||
|
|
@ -66,6 +76,7 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
||||||
color,
|
color,
|
||||||
faceColor = 'rgba(255,255,255,0.04)',
|
faceColor = 'rgba(255,255,255,0.04)',
|
||||||
grid = 4,
|
grid = 4,
|
||||||
|
detentSize = 0.025,
|
||||||
showValue = true,
|
showValue = true,
|
||||||
formatX,
|
formatX,
|
||||||
formatY,
|
formatY,
|
||||||
|
|
@ -124,16 +135,39 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
||||||
onChange?.(c, { source: 'drag' });
|
onChange?.(c, { source: 'drag' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Normalized travel <-> value per axis, honoring `invert`.
|
||||||
|
const valueFromN = (n: number, a: typeof ax) => {
|
||||||
|
const span = a.max - a.min;
|
||||||
|
return a.invert ? a.max - n * span : a.min + n * span;
|
||||||
|
};
|
||||||
|
const nFromValue = (v: number, a: typeof ax) => {
|
||||||
|
const span = a.max - a.min;
|
||||||
|
if (span === 0) return 0;
|
||||||
|
return clamp(a.invert ? (a.max - v) / span : (v - a.min) / span, 0, 1);
|
||||||
|
};
|
||||||
|
const detentsNX = React.useMemo(
|
||||||
|
() => ax.detents?.map(d => nFromValue(d, ax)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[ax.detents, ax.min, ax.max, ax.invert],
|
||||||
|
);
|
||||||
|
const detentsNY = React.useMemo(
|
||||||
|
() => ay.detents?.map(d => nFromValue(d, ay)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[ay.detents, ay.min, ay.max, ay.invert],
|
||||||
|
);
|
||||||
|
|
||||||
const applyPointer = (clientX: number, clientY: number) => {
|
const applyPointer = (clientX: number, clientY: number) => {
|
||||||
const s = session.current;
|
const s = session.current;
|
||||||
if (!s || s.rect.width <= 0 || s.rect.height <= 0) return;
|
if (!s || s.rect.width <= 0 || s.rect.height <= 0) return;
|
||||||
const nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
|
let nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
|
||||||
const ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1);
|
let ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1);
|
||||||
emit({ x: ax.min + nx * (ax.max - ax.min), y: ay.min + ny * (ay.max - ay.min) });
|
if (detentsNX) nx = applyDetents(nx, detentsNX, detentSize);
|
||||||
|
if (detentsNY) ny = applyDetents(ny, detentsNY, detentSize);
|
||||||
|
emit({ x: valueFromN(nx, ax), y: valueFromN(ny, ay) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const nx = ax.max === ax.min ? 0 : (pos.x - ax.min) / (ax.max - ax.min);
|
const nx = nFromValue(pos.x, ax);
|
||||||
const ny = ay.max === ay.min ? 0 : (pos.y - ay.min) / (ay.max - ay.min);
|
const ny = nFromValue(pos.y, ay);
|
||||||
const hx = nx * width;
|
const hx = nx * width;
|
||||||
const hy = (1 - ny) * height;
|
const hy = (1 - ny) * height;
|
||||||
|
|
||||||
|
|
@ -153,12 +187,14 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
width,
|
width,
|
||||||
|
opacity: disabled ? 0.45 : undefined,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -238,10 +274,11 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
||||||
let handled = true;
|
let handled = true;
|
||||||
const p = posRef.current;
|
const p = posRef.current;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowLeft': emit({ ...p, x: p.x - stepX }); break;
|
// Arrows follow the VISUAL direction, so inverted axes negate.
|
||||||
case 'ArrowRight': emit({ ...p, x: p.x + stepX }); break;
|
case 'ArrowLeft': emit({ ...p, x: p.x - (ax.invert ? -stepX : stepX) }); break;
|
||||||
case 'ArrowUp': emit({ ...p, y: p.y + stepY }); break;
|
case 'ArrowRight': emit({ ...p, x: p.x + (ax.invert ? -stepX : stepX) }); break;
|
||||||
case 'ArrowDown': emit({ ...p, y: p.y - stepY }); break;
|
case 'ArrowUp': emit({ ...p, y: p.y + (ay.invert ? -stepY : stepY) }); break;
|
||||||
|
case 'ArrowDown': emit({ ...p, y: p.y - (ay.invert ? -stepY : stepY) }); break;
|
||||||
default: handled = false;
|
default: handled = false;
|
||||||
}
|
}
|
||||||
if (handled) e.preventDefault();
|
if (handled) e.preventDefault();
|
||||||
|
|
@ -262,7 +299,6 @@ export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair',
|
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair',
|
||||||
opacity: disabled ? 0.45 : undefined,
|
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
|
||||||
|
|
|
||||||
45
packages/dreamknob/src/skins/meterEngine.test.ts
Normal file
45
packages/dreamknob/src/skins/meterEngine.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
167
packages/dreamknob/src/skins/meterEngine.ts
Normal file
167
packages/dreamknob/src/skins/meterEngine.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -9,6 +9,13 @@ export interface SkinProps extends KnobCoreProps {
|
||||||
size?: number;
|
size?: number;
|
||||||
/** Caption drawn in the travel gap at the bottom of the knob. */
|
/** Caption drawn in the travel gap at the bottom of the knob. */
|
||||||
label?: string;
|
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. */
|
/** Show the numeric readout. Default varies per skin. */
|
||||||
showValue?: boolean;
|
showValue?: boolean;
|
||||||
/** Unit suffix for the readout, e.g. "dB", "Hz", "%". */
|
/** Unit suffix for the readout, e.g. "dB", "Hz", "%". */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue