From d8b7b55505f856e9876e61a9606365e644adc97e Mon Sep 17 00:00:00 2001
From: Dreamodus
Date: Mon, 13 Jul 2026 12:07:52 -0700
Subject: [PATCH] v1.1.0: integration-feedback release
Bugs from real-product integration:
- Meter/LEDFader clamp geometry below breadth 14 (no negative SVG rects)
- mixColors falls back to CSS color-mix() so theme tokens can be var()/oklch()
- type-in editing fires a real edit gesture (Start/onChange/End, source 'edit')
- ScrubField Enter/Escape no longer double-commit or commit-on-revert via blur
- disabled dims the whole control uniformly, data-disabled on the wrapper
New capabilities:
- commitMode 'release': drags preview, emit once on pointer-up, Esc discards
- Meter accepts value=[l, r, ...]; theme zoneGood/zoneWarn/zoneHot defaults
- XYPad per-axis invert + detents; Fader valuePosition top|end|none
- LED skins: unit labels, uncapped displayDecimals, aria-valuetext defaults
New components: IndicatorLamp, SegmentSwitch, ScrubField, MeterBridge,
PushButton led='dot'. Docs: change-contract table, recipes, gallery cards.
---
README.md | 7 +-
apps/docs/src/sections/ApiDocs.tsx | 99 +++++-
apps/docs/src/sections/Gallery.tsx | 100 +++++-
apps/docs/src/sections/GettingStarted.tsx | 4 +-
apps/docs/src/sections/Hero.tsx | 4 +-
packages/dreamknob/CHANGELOG.md | 42 +++
packages/dreamknob/README.md | 10 +-
packages/dreamknob/package.json | 2 +-
packages/dreamknob/src/components/Knob.tsx | 2 +-
packages/dreamknob/src/core/colors.test.ts | 10 +-
packages/dreamknob/src/core/colors.ts | 14 +-
packages/dreamknob/src/core/theme.tsx | 12 +
packages/dreamknob/src/core/types.ts | 23 +-
packages/dreamknob/src/hooks/useKnob.ts | 72 ++++-
packages/dreamknob/src/index.ts | 4 +
packages/dreamknob/src/skins/Fader.tsx | 155 +++++----
packages/dreamknob/src/skins/FlatKnob.tsx | 1 +
packages/dreamknob/src/skins/ImageKnob.tsx | 12 +-
.../dreamknob/src/skins/IndicatorLamp.tsx | 184 +++++++++++
packages/dreamknob/src/skins/LEDFader.tsx | 51 ++-
packages/dreamknob/src/skins/LEDKnob.tsx | 28 +-
packages/dreamknob/src/skins/MetalKnob.tsx | 2 +-
packages/dreamknob/src/skins/Meter.tsx | 187 ++++++-----
packages/dreamknob/src/skins/MeterBridge.tsx | 124 ++++++++
packages/dreamknob/src/skins/NeonKnob.tsx | 2 +-
packages/dreamknob/src/skins/PushButton.tsx | 26 +-
packages/dreamknob/src/skins/RubberKnob.tsx | 2 +-
packages/dreamknob/src/skins/ScrubField.tsx | 294 ++++++++++++++++++
.../dreamknob/src/skins/SegmentSwitch.tsx | 162 ++++++++++
packages/dreamknob/src/skins/SteppedKnob.tsx | 1 +
packages/dreamknob/src/skins/XYPad.tsx | 58 +++-
31 files changed, 1471 insertions(+), 223 deletions(-)
create mode 100644 packages/dreamknob/src/skins/IndicatorLamp.tsx
create mode 100644 packages/dreamknob/src/skins/MeterBridge.tsx
create mode 100644 packages/dreamknob/src/skins/ScrubField.tsx
create mode 100644 packages/dreamknob/src/skins/SegmentSwitch.tsx
diff --git a/README.md b/README.md
index 6f3f7a5..d40beb9 100644
--- a/README.md
+++ b/README.md
@@ -35,8 +35,9 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
power / custom tapers.
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
- `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `XYPad`,
- `PushButton`, `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance.
+ `SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `MeterBridge`,
+ `XYPad`, `PushButton`, `IndicatorLamp`, `SegmentSwitch`, `ScrubField`,
+ `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance.
- **Composable**: `` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
`useKnob`.
@@ -48,7 +49,7 @@ See `packages/dreamknob/README.md` and the docs app for the full API.
The easiest way is the published release on our Forgejo:
```bash
-pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz
+pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz
```
Or build locally — the package builds to `packages/dreamknob/dist` (ESM + CJS + types):
diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx
index a7664eb..20ff93c 100644
--- a/apps/docs/src/sections/ApiDocs.tsx
+++ b/apps/docs/src/sections/ApiDocs.tsx
@@ -67,8 +67,9 @@ export const ApiDocs: React.FC = () => (
-
-
+
+
+
@@ -87,21 +88,56 @@ export const ApiDocs: React.FC = () => (
via pointer capture · every control shows a keyboard-only focus ring.
+ The change contract
+
+ Every way a value can move maps to a ChangeMeta.source, and every
+ user gesture is bracketed by exactly one onChangeStart /{' '}
+ onChangeEnd pair — wire undo snapshots to Start and history commits
+ to End and the ledger always balances.
+
+
+
+
+ source
+ Emitted by
+ Start/End?
+ onChange cadence
+
+
+
+ 'drag' pointer drags (rotary, linear, track, XY, scrub) yes — one pair per drag each snapped value ('live') or once on release ('release')
+ 'wheel' scroll wheel / trackpad yes — one pair per burst (150–250 ms settle) each notch
+ 'keyboard' arrows, PageUp/Down, Home/End yes — one pair per press each step
+ 'reset' double-click reset yes once
+ 'edit' type-in editors (editable, ScrubField) yes once, on commit
+ 'api' setValue() from your code no — programmatic moves are not gestureseach call that lands on a new value
+
+
+
+ A cancelled gesture (Esc, or disabled flipping mid-drag) restores the
+ start value and still fires onChangeEnd, so Start/End counts always
+ match. onChange never repeats a value within a gesture.
+
+
Prebuilt skins
-
+
-
-
+
+
-
-
-
+
+
+
+
+
+
+
@@ -118,13 +154,19 @@ export const ApiDocs: React.FC = () => (
label) and every interactive root exposes{' '}
data-dragging/data-disabled/data-focus-visible{' '}
— style any state or part with plain CSS. <Arc> additionally
- accepts gradient (position-anchored color stops).
+ accepts gradient (position-anchored color stops). Geometry is
+ self-defending: Meter/LEDFader clamp their internal
+ padding below breadth 14 and never emit negative dimensions, so
+ 4-px status slivers render fine.
Theming
Every skin resolves its color and font defaults from the active theme; per-instance
- props always win. Use base="light" for light backgrounds.
+ props always win. Use base="light" for light backgrounds. Any CSS
+ color works — hex, rgb(), oklch(), even{' '}
+ var(--accent): gradient and glow math uses hex fast-paths where it
+ can and falls back to CSS color-mix() everywhere else.
(
text: '#fff',
label: 'rgba(255,255,255,0.5)',
fontMono: '"IBM Plex Mono", monospace',
+ zoneGood: '#3df2ad', // meter zones: green / amber / red
+ zoneWarn: '#ffd23e', // used by Meter, MeterBridge and
+ zoneHot: '#ff4d6b', // LEDFader when no zones are given
}}
>
@@ -189,6 +234,40 @@ function MyKnob() {
}`}
/>
+ Recipes
+
+ The prop combinations we reach for in real consoles and inspectors.
+
+
+
+// Angle dial — endless, wraps 0↔360, full-circle travel
+
+
+// Expensive parameter — drag previews, engine hears one change on release
+ rebuildReverbTail(v)} aria-label="Room size" />
+
+// Wide-range filter cutoff — log taper puts the musical range under your thumb
+ v >= 1000 ? (v / 1000).toFixed(1) + ' kHz' : v + ' Hz'}
+ aria-label="Cutoff" />
+
+// Inspector knob — relative drag (no grab-jump), flick fast / drag slow
+
+
+// One-line inspector row — fader with the readout beside it
+
+
+// Stereo meter — one component, two channels, shared clip LED
+ `}
+ />
+
Precision & number handling
Values are snapped to step (anchored at min) and rounded
diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx
index 774e08e..eebd097 100644
--- a/apps/docs/src/sections/Gallery.tsx
+++ b/apps/docs/src/sections/Gallery.tsx
@@ -8,14 +8,18 @@ import {
ImageKnob,
Knob,
KnobValue,
+ IndicatorLamp,
LEDFader,
LEDKnob,
MetalKnob,
Meter,
+ MeterBridge,
NeonKnob,
Pointer,
PushButton,
RubberKnob,
+ ScrubField,
+ SegmentSwitch,
SegmentDisplay,
SteppedKnob,
Ticks,
@@ -117,15 +121,78 @@ const MeterDemo: React.FC = () => {
}, 90);
return () => clearInterval(id);
}, []);
- const zones = [
- { upTo: 0.72, color: '#3df2ad' },
- { upTo: 0.9, color: '#ffd23e' },
- { upTo: 1, color: '#ff4d6b' },
- ];
return (
-
-
-
+
+
+
+
+ );
+};
+
+const LampSwitchDemo: React.FC = () => {
+ const [mode, setMode] = React.useState(1);
+ const [armed, setArmed] = React.useState(true);
+ return (
+
+ );
+};
+
+const CommitModeDemo: React.FC = () => {
+ const [commits, setCommits] = React.useState(0);
+ return (
+
+
setCommits(n => n + 1)}
+ aria-label="Release commit demo"
+ />
+
+
+
+ onChange calls
+
+
);
};
@@ -343,10 +410,25 @@ export const Gallery: React.FC = () => (
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(
) · peer deps: react >= 18, react-dom >= 18`}
+ code={`pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz\n# (or npm install ) · peer deps: react >= 18, react-dom >= 18`}
/>
Installs straight from the{' '}
- v1.0.0 release
+ v1.1.0 release
{' '}
on our Forgejo. Zero runtime dependencies, ESM + CJS, full TypeScript types. No
CSS file to import — everything is SVG and inline styles.
diff --git a/apps/docs/src/sections/Hero.tsx b/apps/docs/src/sections/Hero.tsx
index f1a37a0..bac156d 100644
--- a/apps/docs/src/sections/Hero.tsx
+++ b/apps/docs/src/sections/Hero.tsx
@@ -25,10 +25,10 @@ export const Hero: React.FC = () => {
range, any decimal precision, and colors you fully control.
-
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz
+
pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz
{
- navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz');
+ navigator.clipboard.writeText('pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz');
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
diff --git a/packages/dreamknob/CHANGELOG.md b/packages/dreamknob/CHANGELOG.md
index a8bfe8b..e3e4c32 100644
--- a/packages/dreamknob/CHANGELOG.md
+++ b/packages/dreamknob/CHANGELOG.md
@@ -1,5 +1,47 @@
# Changelog
+## 1.1.0 — 2026-07-13
+
+Integration-feedback release — everything in here came from wiring dreamknob into a
+real product.
+
+### New components
+- `IndicatorLamp` — pressable LED lamp (`role="switch"`), toggle/momentary, `blink`
+ (reduced-motion aware), `readOnly` status-light mode
+- `SegmentSwitch` — hardware-styled segmented selector (radiogroup, roving focus,
+ arrow/Home/End keys, LED tick)
+- `ScrubField` — After-Effects-style numeric field: label scrubbing (Shift = fine),
+ draft typing (Enter commits, Esc reverts), arrow steps, wheel; `role="spinbutton"`
+- `MeterBridge` — labeled multi-strip meter block with per-channel peak-hold text
+- `PushButton` gains `led="dot"` — corner-dot LED that scales down for small
+ icon-only toggles (`led` now `boolean | 'strip' | 'dot'`)
+
+### New capabilities
+- `commitMode="release"` — drags preview visually but emit a single `onChange` on
+ pointer-up; Esc discards the preview (expensive parameters)
+- `Meter` accepts `value={[l, r, ...]}` for multi-channel bars with a shared clip LED
+- Theme gains `zoneGood` / `zoneWarn` / `zoneHot`; `Meter`, `MeterBridge` and
+ `LEDFader` default their zones from the theme
+- `XYPad` axes accept `invert` and per-axis `detents`; arrow keys follow the visual
+ direction on inverted axes
+- `Fader` gains `valuePosition: 'top' | 'end' | 'none'` — `'end'` renders one-line
+ inspector rows with a stable readout width
+- `LEDKnob` / `LEDFader` / `Meter` gain `unit`; `displayDecimals` is no longer capped
+- Skins without visible readouts now default `aria-valuetext` from `format`/`unit`
+ (`SteppedKnob` announces position names)
+
+### Fixes
+- `Meter` clamps geometry below `breadth` 14 — no negative-dimension SVG rects on
+ thin strips
+- `mixColors` falls back to CSS `color-mix()` for non-hex inputs, so theme tokens can
+ be `var(--x)` / `oklch()` without breaking gradients and glows
+- Type-in editing now fires a proper edit gesture: `onChangeStart` → `onChange` →
+ `onChangeEnd` with `source: 'edit'` (was a bare `'api'` set with no gesture events)
+- The `ChangeMeta.source` contract is documented (docs + `types.ts`): every user
+ gesture brackets exactly one Start/End pair; `'api'` fires neither
+- Disabled controls set `data-disabled` on the wrapper (including labels/readouts)
+ and dim uniformly
+
## 1.0.0 — 2026-07-12
Initial release.
diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md
index ab1ed6a..cc9f589 100644
--- a/packages/dreamknob/README.md
+++ b/packages/dreamknob/README.md
@@ -5,7 +5,7 @@ headless core, any number range, any decimal precision, and colors you fully con
Zero runtime dependencies.
```
-pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.0.0/dreamknob-1.0.0.tgz
+pnpm add https://git.dreamodus.software/Dreamodus/DreamKnob/releases/download/v1.1.0/dreamknob-1.1.0.tgz
```
(Latest release tarballs: https://git.dreamodus.software/Dreamodus/DreamKnob/releases)
@@ -98,9 +98,13 @@ import {
Fader, // linear channel fader, vertical or horizontal
LEDFader, // segmented LED meter-fader with color zones
ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
- Meter, // read-only LED level meter with peak hold + latching clip LED
+ Meter, // read-only LED level meter — mono or multi-channel (value={[l, r]})
+ MeterBridge, // labeled multi-strip meter block with per-channel peak text
XYPad, // two-parameter pad (cutoff/resonance, vector mixing)
- PushButton, // panel button with LED strip (toggle or momentary)
+ PushButton, // panel button with LED strip or corner dot (toggle or momentary)
+ IndicatorLamp, // pressable LED lamp — the hardware checkbox (blink, momentary)
+ SegmentSwitch, // segmented option selector (radiogroup, LED tick)
+ ScrubField, // AE-style numeric field: drag the label to scrub, click to type
SegmentDisplay, // standalone seven-segment numeric display
AlphaDisplay, // fourteen-segment alphanumeric display
} from 'dreamknob'
diff --git a/packages/dreamknob/package.json b/packages/dreamknob/package.json
index 0741bc1..0382a94 100644
--- a/packages/dreamknob/package.json
+++ b/packages/dreamknob/package.json
@@ -1,6 +1,6 @@
{
"name": "dreamknob",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
"license": "MIT",
"author": "Dreamodus Software Inc.",
diff --git a/packages/dreamknob/src/components/Knob.tsx b/packages/dreamknob/src/components/Knob.tsx
index ca171c8..a5942e5 100644
--- a/packages/dreamknob/src/components/Knob.tsx
+++ b/packages/dreamknob/src/components/Knob.tsx
@@ -127,7 +127,7 @@ export const Knob = React.forwardRef(function Knob(
initial={String(knob.value)}
onCommit={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();
}}
onCancel={closeEditor}
diff --git a/packages/dreamknob/src/core/colors.test.ts b/packages/dreamknob/src/core/colors.test.ts
index dc2a2fb..0109fe9 100644
--- a/packages/dreamknob/src/core/colors.test.ts
+++ b/packages/dreamknob/src/core/colors.test.ts
@@ -10,9 +10,13 @@ describe('mixColors', () => {
it('expands shorthand hex', () => {
expect(mixColors('#f00', '#f00', 0.5)).toBe('#ff0000');
});
- it('falls back to the nearer endpoint for non-hex input', () => {
- expect(mixColors('red', '#00ff00', 0.2)).toBe('red');
- expect(mixColors('red', '#00ff00', 0.8)).toBe('#00ff00');
+ it('produces color-mix() for non-hex input (CSS vars, named colors)', () => {
+ expect(mixColors('var(--accent)', '#00ff00', 0.25)).toBe(
+ '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)',
+ );
});
});
diff --git a/packages/dreamknob/src/core/colors.ts b/packages/dreamknob/src/core/colors.ts
index c5ee54b..03c36be 100644
--- a/packages/dreamknob/src/core/colors.ts
+++ b/packages/dreamknob/src/core/colors.ts
@@ -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];
};
-/** 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 => {
const ca = parseHex(a);
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 [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');
@@ -26,6 +34,8 @@ export const mixColors = (a: string, b: string, t: number): string => {
: `#${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`;
* positions outside the stops clamp to the nearest end.
diff --git a/packages/dreamknob/src/core/theme.tsx b/packages/dreamknob/src/core/theme.tsx
index 773850f..2f16a86 100644
--- a/packages/dreamknob/src/core/theme.tsx
+++ b/packages/dreamknob/src/core/theme.tsx
@@ -28,6 +28,12 @@ export interface KnobTheme {
ticks: string;
/** Keyboard focus ring. */
focusRing: string;
+ /** Meter/LED zone semantics: healthy level. */
+ zoneGood: string;
+ /** Meter/LED zone semantics: getting hot. */
+ zoneWarn: string;
+ /** Meter/LED zone semantics: too hot / clipping. */
+ zoneHot: string;
fontMono: string;
fontUI: string;
}
@@ -39,6 +45,9 @@ export const darkTheme: KnobTheme = {
label: 'rgba(255,255,255,0.45)',
ticks: 'rgba(255,255,255,0.18)',
focusRing: 'rgba(130,180,255,0.65)',
+ zoneGood: '#3df2ad',
+ zoneWarn: '#ffd23e',
+ zoneHot: '#ff4d6b',
fontMono: MONO_FONT,
fontUI: UI_FONT,
};
@@ -50,6 +59,9 @@ export const lightTheme: KnobTheme = {
label: 'rgba(0,0,0,0.5)',
ticks: 'rgba(0,0,0,0.28)',
focusRing: 'rgba(0,110,220,0.55)',
+ zoneGood: '#0fa571',
+ zoneWarn: '#c99000',
+ zoneHot: '#d92546',
fontMono: MONO_FONT,
fontUI: UI_FONT,
};
diff --git a/packages/dreamknob/src/core/types.ts b/packages/dreamknob/src/core/types.ts
index 4afa903..66349d8 100644
--- a/packages/dreamknob/src/core/types.ts
+++ b/packages/dreamknob/src/core/types.ts
@@ -1,7 +1,18 @@
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 {
source: ChangeSource;
@@ -80,6 +91,14 @@ export interface KnobCoreProps {
* `prefers-reduced-motion` disables it. Default: false.
*/
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'. */
interaction?: InteractionMode;
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */
diff --git a/packages/dreamknob/src/hooks/useKnob.ts b/packages/dreamknob/src/hooks/useKnob.ts
index 75b3bdc..d01d340 100644
--- a/packages/dreamknob/src/hooks/useKnob.ts
+++ b/packages/dreamknob/src/hooks/useKnob.ts
@@ -48,8 +48,10 @@ export interface UseKnobResult extends KnobState {
'data-focus-visible'?: string;
style: React.CSSProperties;
};
- /** Imperatively set the value (snapped + clamped). */
+ /** Imperatively set the value (snapped + clamped). Source: 'api' — no Start/End. */
setValue: (value: number) => void;
+ /** Commit a value as an edit gesture: fires onChangeStart → onChange → onChangeEnd with source 'edit'. */
+ commitValue: (value: number) => void;
}
interface DragSession {
@@ -94,6 +96,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
detentSize = 0.025,
wrap = false,
animateChanges = false,
+ commitMode = 'live',
fineMultiplier = 0.1,
angleOffset = 225,
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, 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(null);
+ const previewRef = React.useRef(null);
+ previewRef.current = previewValue;
+
const setFromDrag = React.useCallback(
- (n: number) =>
- setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n, 'drag'),
- [setFromNormalized, detentsN, detentSize],
+ (n: number) => {
+ const nn = detentsN ? applyDetents(n, detentsN, detentSize) : n;
+ 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],
);
- const endDrag = React.useCallback((e: React.PointerEvent) => {
- if (!session.current || e.pointerId !== session.current.pointerId) return;
- session.current = null;
- setIsDragging(false);
- onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
- }, []);
+ const endDrag = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (!session.current || e.pointerId !== session.current.pointerId) return;
+ session.current = null;
+ 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' });
+ }
+ },
+ [emit],
+ );
// If the control becomes disabled/readOnly mid-gesture, cancel the drag —
// otherwise it would keep emitting while rendered as disabled.
@@ -347,6 +384,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
if (interactive || !session.current) return;
session.current = null;
setIsDragging(false);
+ setPreviewValue(null);
onChangeEndRef.current?.(valueRef.current, { source: 'drag' });
}, [interactive]);
@@ -473,7 +511,12 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
e.preventDefault();
session.current = null;
setIsDragging(false);
- emit(s.startValue, 'drag');
+ if (previewRef.current !== null) {
+ // release-mode: nothing was committed — just discard the preview.
+ setPreviewValue(null);
+ } else {
+ emit(s.startValue, 'drag');
+ }
onChangeEndRef.current?.(s.startValue, { source: 'drag' });
return;
}
@@ -594,7 +637,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
};
}, [value, animMs]);
- const shownValue = animMs ? displayValue : value;
+ const shownValue = previewValue ?? (animMs ? displayValue : value);
const normalized = clamp(taper.toNormalized(shownValue, min, max), 0, 1);
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
const originNormalized =
@@ -603,7 +646,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
: clamp(taper.toNormalized(clamp(origin, min, max), min, max), 0, 1);
return {
- value,
+ value: previewValue ?? value,
normalized,
originNormalized,
angle,
@@ -616,6 +659,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
angleRange,
ref,
setValue: emit,
+ commitValue,
bind: {
ref,
onPointerDown,
@@ -631,7 +675,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
tabIndex: disabled ? -1 : 0,
'aria-valuemin': min,
'aria-valuemax': max,
- 'aria-valuenow': value,
+ 'aria-valuenow': previewValue ?? value,
'aria-orientation':
interaction === 'track-vertical'
? ('vertical' as const)
diff --git a/packages/dreamknob/src/index.ts b/packages/dreamknob/src/index.ts
index 4c34590..1b53621 100644
--- a/packages/dreamknob/src/index.ts
+++ b/packages/dreamknob/src/index.ts
@@ -67,6 +67,10 @@ export { Fader, type FaderProps } from './skins/Fader';
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { Meter, type MeterProps } from './skins/Meter';
+export { MeterBridge, type MeterBridgeProps, type MeterBridgeChannel } from './skins/MeterBridge';
export { PushButton, type PushButtonProps } from './skins/PushButton';
+export { IndicatorLamp, type IndicatorLampProps } from './skins/IndicatorLamp';
+export { SegmentSwitch, type SegmentSwitchProps } from './skins/SegmentSwitch';
+export { ScrubField, type ScrubFieldProps } from './skins/ScrubField';
export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
diff --git a/packages/dreamknob/src/skins/Fader.tsx b/packages/dreamknob/src/skins/Fader.tsx
index 50a57dd..42ba810 100644
--- a/packages/dreamknob/src/skins/Fader.tsx
+++ b/packages/dreamknob/src/skins/Fader.tsx
@@ -28,6 +28,12 @@ export interface FaderProps extends Omit {
format?: (value: number) => string;
textColor?: 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. */
editable?: boolean;
/** Custom parser for typed input. */
@@ -57,6 +63,7 @@ export const Fader = React.forwardRef(function Fader
format,
textColor,
labelColor,
+ valuePosition = 'top',
editable = false,
parseValue,
name,
@@ -78,7 +85,11 @@ export const Fader = React.forwardRef(function Fader
// Align pointer mapping with the handle's travel so grabbing the cap
// doesn't jump the value.
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 capCross = breadth * 0.62;
@@ -120,76 +131,68 @@ export const Fader = React.forwardRef(function Fader
);
}
- return (
+ const atEnd = valuePosition === 'end';
+ const readout = showReadout && (
- {showValue && (
-
{
+ const parsed = (parseValue ?? defaultParseValue)(raw);
+ if (parsed !== null && Number.isFinite(parsed)) knob.commitValue(parsed);
+ setEditing(false);
+ }}
+ onCancel={() => setEditing(false)}
+ style={{ position: 'static', transform: 'none', width: atEnd ? '9ch' : '100%', fontSize: 12 }}
+ />
+ ) : (
+ setEditing(true) : undefined}
+ title={editable ? 'Click to type a value' : undefined}
style={{
- width: '100%',
- display: 'flex',
- justifyContent: 'center',
- position: 'relative',
- minHeight: '1.4em',
+ fontFamily: theme.fontMono,
+ fontSize: 12,
+ color: textColor ?? theme.text,
+ minHeight: '1.2em',
+ whiteSpace: 'nowrap',
+ pointerEvents: editable ? 'auto' : 'none',
+ cursor: editable ? 'text' : undefined,
}}
>
- {editing ? (
- {
- const parsed = (parseValue ?? defaultParseValue)(raw);
- if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
- setEditing(false);
- }}
- onCancel={() => setEditing(false)}
- style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
- />
- ) : (
- setEditing(true) : undefined}
- title={editable ? 'Click to type a value' : undefined}
- style={{
- fontFamily: theme.fontMono,
- fontSize: 12,
- color: textColor ?? theme.text,
- minHeight: '1.2em',
- whiteSpace: 'nowrap',
- pointerEvents: editable ? 'auto' : 'none',
- cursor: editable ? 'text' : undefined,
- }}
- >
- {text}
-
- )}
-
+ {text}
+
)}
-
{
- (knob.bind.ref as React.MutableRefObject).current = node;
- if (typeof ref === 'function') ref(node);
- else if (ref) ref.current = node;
- }}
- style={{
- ...knob.bind.style,
- width: w,
- height: h,
- display: 'inline-flex',
- borderRadius: 8,
- ...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
- }}
- >
+
+ );
+
+ const control = (
+
{
+ (knob.bind.ref as React.MutableRefObject).current = node;
+ if (typeof ref === 'function') ref(node);
+ else if (ref) ref.current = node;
+ }}
+ style={{
+ ...knob.bind.style,
+ opacity: undefined, // the outer wrapper owns disabled dimming
+ width: w,
+ height: h,
+ display: 'inline-flex',
+ borderRadius: 8,
+ ...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
+ }}
+ >
{capColor ? (
@@ -261,6 +264,36 @@ export const Fader = React.forwardRef(function Fader
{name && }
+ );
+
+ return (
+
+ {valuePosition === 'top' && readout}
+ {atEnd && !vertical ? (
+
+ {control}
+ {readout}
+
+ ) : (
+ <>
+ {control}
+ {atEnd && readout}
+ >
+ )}
{label && (
(function
style={style}
focusRing={focusRing ?? theme.focusRing}
{...core}
+ getAriaValueText={core.getAriaValueText ?? (format ? (v: number) => format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}
>
(functi
...core
}, ref) {
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 ring = focusRing ?? theme.focusRing;
@@ -82,12 +87,14 @@ export const ImageKnob = React.forwardRef(functi
return (
@@ -108,6 +115,7 @@ export const ImageKnob = React.forwardRef
(functi
}
style={{
...knob.bind.style,
+ opacity: undefined, // the outer wrapper owns disabled dimming
position: 'relative',
width: size,
height: size,
@@ -133,7 +141,7 @@ export const ImageKnob = React.forwardRef(functi
initial={String(knob.value)}
onCommit={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();
}}
onCancel={closeEditor}
diff --git a/packages/dreamknob/src/skins/IndicatorLamp.tsx b/packages/dreamknob/src/skins/IndicatorLamp.tsx
new file mode 100644
index 0000000..8154410
--- /dev/null
+++ b/packages/dreamknob/src/skins/IndicatorLamp.tsx
@@ -0,0 +1,184 @@
+import * as React from 'react';
+import { useKnobTheme } from '../core/theme';
+
+export interface IndicatorLampProps {
+ /** Controlled on-state. Pair with `onChange`. */
+ on?: boolean;
+ defaultOn?: boolean;
+ onChange?: (on: boolean) => void;
+ /** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
+ mode?: 'toggle' | 'momentary';
+ /** Lamp color when lit. */
+ color?: string;
+ /** Lamp diameter in px. Default: 12. */
+ size?: number;
+ shape?: 'round' | 'square';
+ /** Pulse while lit — armed/recording states. Respects prefers-reduced-motion. */
+ blink?: boolean;
+ /** Pure status light: not pressable, not focusable, no switch semantics. */
+ readOnly?: boolean;
+ /** Caption beside the lamp. */
+ label?: string;
+ labelPosition?: 'right' | 'left';
+ disabled?: boolean;
+ /** Render a hidden form input carrying "on"/"off". */
+ name?: string;
+ className?: string;
+ style?: React.CSSProperties;
+ 'aria-label'?: string;
+}
+
+/**
+ * A pressable LED lamp — the hardware-real toggle for dense headers and
+ * checkbox duty (`● CUE ● MUTE`). Renders `role="switch"`.
+ */
+export const IndicatorLamp = React.forwardRef(
+ function IndicatorLamp(
+ {
+ on,
+ defaultOn = false,
+ onChange,
+ mode = 'toggle',
+ color,
+ size = 12,
+ shape = 'round',
+ blink = false,
+ readOnly = false,
+ label,
+ labelPosition = 'right',
+ disabled = false,
+ name,
+ className,
+ style,
+ ...aria
+ },
+ ref,
+ ) {
+ const theme = useKnobTheme();
+ const accent = color ?? theme.accent ?? '#3df2ad';
+ const isControlled = on !== undefined;
+ const [internal, setInternal] = React.useState(defaultOn);
+ const [focusVisible, setFocusVisible] = React.useState(false);
+ const lit = isControlled ? (on as boolean) : internal;
+ const ledRef = React.useRef(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 = (
+
+ );
+
+ return (
+ {
+ 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 && (
+
+ {label}
+
+ )}
+ {name && }
+
+ );
+ },
+);
diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx
index a57f78a..0fec015 100644
--- a/packages/dreamknob/src/skins/LEDFader.tsx
+++ b/packages/dreamknob/src/skins/LEDFader.tsx
@@ -38,8 +38,10 @@ export interface LEDFaderProps extends Omit {
showValue?: boolean;
/** Digit cells in the readout. Default: 4. */
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;
+ /** Unit suffix beside the readout, e.g. "dB". */
+ unit?: string;
labelColor?: string;
/** Click the readout to type an exact value. */
editable?: boolean;
@@ -67,6 +69,7 @@ export const LEDFader = React.forwardRef(function
showValue = true,
digits = 4,
displayDecimals,
+ unit,
labelColor,
editable = false,
parseValue,
@@ -77,7 +80,7 @@ export const LEDFader = React.forwardRef(function
}, ref) {
const [editing, setEditing] = React.useState(false);
const theme = useKnobTheme();
- const color = colorProp ?? theme.accent ?? '#3df2ad';
+ const color = colorProp ?? theme.accent;
const ring = focusRing ?? theme.focusRing;
const vertical = orientation === 'vertical';
const pad = 5;
@@ -85,16 +88,22 @@ export const LEDFader = React.forwardRef(function
...core,
interaction: vertical ? 'track-vertical' : 'track-horizontal',
trackInset: pad,
+ getAriaValueText: core.getAriaValueText ?? (unit ? v => `${v} ${unit}` : undefined),
});
const id = React.useId();
const glowId = `${id}-glow`;
const w = vertical ? breadth : length;
const h = vertical ? length : breadth;
- const zoneList: readonly LEDFaderZone[] = React.useMemo(
- () => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
- [zones, color],
- );
+ 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 =>
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
@@ -142,12 +151,14 @@ export const LEDFader = React.forwardRef(function
return (
@@ -171,22 +182,29 @@ export const LEDFader = React.forwardRef(function
initial={String(knob.value)}
onCommit={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);
}}
onCancel={() => setEditing(false)}
style={{ position: 'static', transform: 'none', width: '100%', fontSize: 12 }}
/>
) : (
-
+
+
+ {unit && (
+
+ {unit}
+
+ )}
+
)}
)}
@@ -199,6 +217,7 @@ export const LEDFader = React.forwardRef(function
}}
style={{
...knob.bind.style,
+ opacity: undefined, // the outer wrapper owns disabled dimming
width: w,
height: h,
display: 'inline-flex',
diff --git a/packages/dreamknob/src/skins/LEDKnob.tsx b/packages/dreamknob/src/skins/LEDKnob.tsx
index 850d7c1..cee45a3 100644
--- a/packages/dreamknob/src/skins/LEDKnob.tsx
+++ b/packages/dreamknob/src/skins/LEDKnob.tsx
@@ -17,7 +17,7 @@ export interface LEDKnobProps extends SkinProps {
segments?: number;
/** Digit cells in the center readout. */
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;
labelColor?: string;
faceColor?: string;
@@ -92,6 +92,7 @@ export const LEDKnob = React.forwardRef(function L
labelColor,
faceColor = '#0b0d0e',
label,
+ unit,
focusRing,
className,
style,
@@ -102,7 +103,15 @@ export const LEDKnob = React.forwardRef(function L
const id = React.useId();
const glowId = `${id}-glow`;
return (
-
+ `${v} ${unit}` : undefined)}
+ >
{ctx => (
<>
@@ -120,9 +129,22 @@ export const LEDKnob = React.forwardRef(function L
+ {unit && (
+
+ {unit}
+
+ )}
{label && (
(functi
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
return (
-
+ format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
diff --git a/packages/dreamknob/src/skins/Meter.tsx b/packages/dreamknob/src/skins/Meter.tsx
index 2ccf9a8..d088b48 100644
--- a/packages/dreamknob/src/skins/Meter.tsx
+++ b/packages/dreamknob/src/skins/Meter.tsx
@@ -6,21 +6,24 @@ import type { LEDFaderZone } from './LEDFader';
import { SegmentDisplay } from '../digital/SegmentDisplay';
export interface MeterProps {
- /** Level to display. The meter is read-only — always controlled. */
- value: number;
+ /**
+ * Level to display — a single number, or an array for a multi-channel
+ * meter (e.g. `[l, r]` for stereo). The meter is read-only.
+ */
+ value: number | readonly number[];
min?: number;
max?: number;
taper?: Taper;
orientation?: 'vertical' | 'horizontal';
/** Travel length in px. Default: 160. */
length?: number;
- /** Cross-axis size in px. Default: 16. */
+ /** Cross-axis size in px PER CHANNEL. Minimum useful: ~6. Default: 16. */
breadth?: number;
/** Number of LED segments. Default: 28. */
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;
- /** Meter zones, e.g. green / yellow / red by normalized position. */
+ /** Meter zones by normalized position. Defaults to theme zoneGood/Warn/Hot. */
zones?: readonly LEDFaderZone[];
/** Opacity of unlit segments. Default: 0.1. */
offOpacity?: number;
@@ -31,40 +34,45 @@ export interface MeterProps {
peakHold?: number | false;
/** Peak indicator color. Defaults to the peak's zone color. */
peakColor?: string;
- /** Dedicated clip LED at the hot end of the meter. Default: true. */
+ /** Dedicated clip LED at the hot end (shared across channels). Default: true. */
showClip?: boolean;
/** Value at/above which the clip LED lights. Default: `max`. */
clipThreshold?: number;
/**
- * Signal used for clip detection, when it differs from the displayed
- * level — e.g. show RMS on the bar but clip on sample peaks. Defaults
- * to `value`.
+ * Signal(s) used for clip detection when different from the displayed
+ * level — e.g. show RMS on the bars but clip on sample peaks. Same shape
+ * 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
* clicked. Default: 1500.
*/
clipHold?: number | 'latch';
clipColor?: string;
- /** Fired when the signal first crosses the clip threshold. */
+ /** Fired when any channel first crosses the clip threshold. */
onClip?: (value: number) => void;
/** Panel color behind the LEDs. */
faceColor?: string;
label?: string;
- /** Show a seven-segment readout. Default: false. */
+ /** Show a seven-segment readout (of the hottest channel). Default: false. */
showValue?: boolean;
digits?: number;
displayDecimals?: number;
+ /** Unit suffix beside the readout, e.g. "dB". */
+ unit?: string;
labelColor?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
+const asArray = (v: number | readonly number[]): readonly number[] =>
+ Array.isArray(v) ? v : [v as number];
+
/**
- * Read-only LED level meter with peak hold — the display-side sibling of
- * LEDFader for VU/output metering.
+ * Read-only LED level meter with peak hold and a latching clip LED — pass an
+ * array of values for stereo/multi-channel bars sharing one clip indicator.
*/
export const Meter: React.FC = ({
value,
@@ -75,7 +83,7 @@ export const Meter: React.FC = ({
length = 160,
breadth = 16,
segments = 28,
- color = '#3df2ad',
+ color,
zones,
offOpacity = 0.1,
peakHold = 1200,
@@ -91,6 +99,7 @@ export const Meter: React.FC = ({
showValue = false,
digits = 4,
displayDecimals = 1,
+ unit,
labelColor,
className,
style,
@@ -98,22 +107,27 @@ export const Meter: React.FC = ({
}) => {
const theme = useKnobTheme();
const vertical = orientation === 'vertical';
- const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1);
+ const values = asArray(value);
+ const channels = values.length;
+ const ns = values.map(v => clamp(taper.toNormalized(clamp(v, min, max), min, max), 0, 1));
- // Peak: track the highest recent level, fall back after `peakHold` ms.
- const [peak, setPeak] = React.useState(n);
+ // Peaks: per channel, falling back together after `peakHold` ms of no rise.
+ const [peaks, setPeaks] = React.useState(ns);
React.useEffect(() => {
if (peakHold === false) return;
- if (n >= peak) {
- setPeak(n);
- return;
- }
- const t = setTimeout(() => setPeak(n), peakHold);
+ setPeaks(prev => {
+ const rose = ns.map((n, i) => n >= (prev[i] ?? 0));
+ if (rose.every(Boolean) || prev.length !== ns.length) return ns.map((n, i) => Math.max(n, prev[i] ?? 0));
+ return prev.map((p, i) => Math.max(p, ns[i] ?? 0));
+ });
+ const t = setTimeout(() => setPeaks(ns), peakHold);
return () => clearTimeout(t);
- }, [n, peak, peakHold]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [JSON.stringify(ns), peakHold]);
- // Clip: latch a dedicated LED when the raw (unclamped) signal reaches the
- // threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode.
+ // Clip: latch when any channel's raw (unclamped) signal reaches threshold.
+ const clipSignals = clipValue !== undefined ? asArray(clipValue) : values;
+ const hottest = Math.max(...clipSignals);
const [clipped, setClipped] = React.useState(false);
const clipTimer = React.useRef>();
const wasOver = React.useRef(false);
@@ -121,10 +135,9 @@ export const Meter: React.FC = ({
onClipRef.current = onClip;
React.useEffect(() => {
if (!showClip) return;
- const signal = clipValue ?? value;
- const over = signal >= (clipThreshold ?? max) - 1e-9;
+ const over = hottest >= (clipThreshold ?? max) - 1e-9;
if (over) {
- if (!wasOver.current) onClipRef.current?.(signal);
+ if (!wasOver.current) onClipRef.current?.(hottest);
setClipped(true);
if (clipHold !== 'latch') {
clearTimeout(clipTimer.current);
@@ -132,59 +145,84 @@ export const Meter: React.FC = ({
}
}
wasOver.current = over;
- }, [value, clipValue, showClip, clipThreshold, max, clipHold]);
+ }, [hottest, showClip, clipThreshold, max, clipHold]);
React.useEffect(() => () => clearTimeout(clipTimer.current), []);
- const pad = 4;
- const w = vertical ? breadth : length;
- const h = vertical ? length : breadth;
- const zoneList: readonly LEDFaderZone[] = React.useMemo(
- () => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
- [zones, color],
- );
+ 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 =>
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
- const lit = Math.round(n * segments);
- const peakIdx =
- peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1;
- // Reserve room at the hot end for the clip LED (7px LED + 3px gap).
+ // Geometry — clamped so thin meters degrade gracefully instead of
+ // producing negative SVG rects. pad shrinks below breadth 14.
+ const pad = breadth < 14 ? 2 : 4;
+ 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 clipSpan = showClip ? clipLed + 3 : 0;
- const slot = (length - pad * 2 - clipSpan) / segments;
+ const slot = Math.max(1, (along - pad * 2 - clipSpan) / segments);
const gap = Math.min(2.5, slot * 0.35);
- const cross = breadth - pad * 2;
+ const segSize = Math.max(0.5, slot - gap);
- const leds: React.ReactNode[] = [];
- for (let i = 0; i < segments; i++) {
- const zone = zoneFor((i + 1) / segments);
- const isPeak = i === peakIdx && i >= lit;
- const on = i < lit || isPeak;
- const fill = isPeak ? (peakColor ?? zone) : zone;
- const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
- leds.push(
- vertical ? (
-
- ) : (
-
- ),
- );
+ const bars: React.ReactNode[] = [];
+ for (let c = 0; c < channels; c++) {
+ const offset = c * (breadth + chGap) + pad;
+ const lit = Math.round((ns[c] ?? 0) * segments);
+ const peakIdx =
+ peakHold !== false && (peaks[c] ?? 0) > 0
+ ? Math.min(segments - 1, Math.ceil((peaks[c] ?? 0) * segments) - 1)
+ : -1;
+ for (let i = 0; i < segments; i++) {
+ const zone = zoneFor((i + 1) / segments);
+ const isPeak = i === peakIdx && i >= lit;
+ const on = i < lit || isPeak;
+ const fill = isPeak ? (peakColor ?? zone) : zone;
+ const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
+ bars.push(
+ vertical ? (
+
+ ) : (
+
+ ),
+ );
+ }
}
+ const hottestShown = Math.max(...values.map(v => clamp(v, min, max)));
+
return (
= ({
}}
>
{showValue && (
-
+
+ {unit && (
+
+ {unit}
+
+ )}
)}
- {leds}
+ {bars}
{showClip && (
{
+ 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 = ({
+ 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(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 (
+
+ {showPeakText && (
+
+ {peaks.map((p, i) => (
+
+ {Math.max(p, min) <= min ? '—' : Math.min(p, max).toFixed(peakTextDecimals)}
+
+ ))}
+
+ )}
+
+
+ {channels.map(c => (
+
+ {c.label}
+
+ ))}
+
+ {label && (
+
+ {label}
+
+ )}
+
+ );
+};
diff --git a/packages/dreamknob/src/skins/NeonKnob.tsx b/packages/dreamknob/src/skins/NeonKnob.tsx
index fb522cb..0b9b0ec 100644
--- a/packages/dreamknob/src/skins/NeonKnob.tsx
+++ b/packages/dreamknob/src/skins/NeonKnob.tsx
@@ -36,7 +36,7 @@ export const NeonKnob = React.forwardRef(function
const id = React.useId();
const glowId = `${id}-glow`;
return (
-
+ format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
(
...style,
}}
>
- {led && (
+ {(led === true || led === 'strip') && (
(
}}
/>
)}
+ {led === 'dot' && (
+
+ )}
{children}
{name && }
diff --git a/packages/dreamknob/src/skins/RubberKnob.tsx b/packages/dreamknob/src/skins/RubberKnob.tsx
index bd70acc..92b2cfa 100644
--- a/packages/dreamknob/src/skins/RubberKnob.tsx
+++ b/packages/dreamknob/src/skins/RubberKnob.tsx
@@ -42,7 +42,7 @@ export const RubberKnob = React.forwardRef(func
const bodyR = size / 2 - size * 0.17;
return (
-
+ format(v) : unit ? (v: number) => `${v}${unit}` : undefined)}>
diff --git a/packages/dreamknob/src/skins/ScrubField.tsx b/packages/dreamknob/src/skins/ScrubField.tsx
new file mode 100644
index 0000000..8bc2fc3
--- /dev/null
+++ b/packages/dreamknob/src/skins/ScrubField.tsx
@@ -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(
+ 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(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) => {
+ 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) => {
+ 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) => {
+ 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(null);
+ const wheelState = React.useRef({ active: false, timer: undefined as ReturnType | 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 (
+
+ {label && (
+
+ {label}
+
+ )}
+ {
+ 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 && (
+
+ {unit}
+
+ )}
+ {name && }
+
+ );
+ },
+);
diff --git a/packages/dreamknob/src/skins/SegmentSwitch.tsx b/packages/dreamknob/src/skins/SegmentSwitch.tsx
new file mode 100644
index 0000000..ae89925
--- /dev/null
+++ b/packages/dreamknob/src/skins/SegmentSwitch.tsx
@@ -0,0 +1,162 @@
+import * as React from 'react';
+import { useKnobTheme } from '../core/theme';
+
+export interface SegmentSwitchProps {
+ /** Position labels, e.g. ['LINEAR', 'RADIAL', 'CONIC']. */
+ options: readonly string[];
+ /** Controlled selected index. Pair with `onChange`. */
+ value?: number;
+ defaultValue?: number;
+ onChange?: (index: number) => void;
+ /** Accent for the active cell's LED tick and text. */
+ color?: string;
+ /** Control height in px. Default: 30. */
+ size?: number;
+ /** Show the LED tick on the active cell. Default: true. */
+ led?: boolean;
+ disabled?: boolean;
+ /** Render a hidden form input carrying the selected option text. */
+ name?: string;
+ className?: string;
+ style?: React.CSSProperties;
+ 'aria-label'?: string;
+}
+
+/**
+ * Hardware-styled segmented selector — the horizontal sibling of
+ * SteppedKnob. Renders a radiogroup; arrow keys move the selection.
+ */
+export const SegmentSwitch = React.forwardRef(
+ function SegmentSwitch(
+ {
+ options,
+ value,
+ defaultValue = 0,
+ onChange,
+ color,
+ size = 30,
+ led = true,
+ disabled = false,
+ name,
+ className,
+ style,
+ ...aria
+ },
+ ref,
+ ) {
+ const theme = useKnobTheme();
+ const accent = color ?? theme.accent ?? '#4cc2ff';
+ const isControlled = value !== undefined;
+ const [internal, setInternal] = React.useState(defaultValue);
+ const active = Math.max(0, Math.min(options.length - 1, isControlled ? (value as number) : internal));
+ const btnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
+
+ const select = (i: number, focus = false) => {
+ const next = Math.max(0, Math.min(options.length - 1, i));
+ if (focus) btnRefs.current[next]?.focus();
+ if (next === active) return;
+ if (!isControlled) setInternal(next);
+ onChange?.(next);
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
+ e.preventDefault();
+ select(active + 1, true);
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ select(active - 1, true);
+ } else if (e.key === 'Home') {
+ e.preventDefault();
+ select(0, true);
+ } else if (e.key === 'End') {
+ e.preventDefault();
+ select(options.length - 1, true);
+ }
+ };
+
+ return (
+
+ {options.map((opt, i) => {
+ const isActive = i === active;
+ return (
+ {
+ btnRefs.current[i] = el;
+ }}
+ type="button"
+ role="radio"
+ aria-checked={isActive}
+ tabIndex={isActive ? 0 : -1}
+ disabled={disabled}
+ data-active={isActive ? '' : undefined}
+ onClick={() => select(i)}
+ style={{
+ display: 'inline-flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 2,
+ padding: '0 12px',
+ minWidth: size * 1.6,
+ border: 'none',
+ borderRight: i < options.length - 1 ? '1px solid rgba(0,0,0,0.55)' : 'none',
+ background: isActive
+ ? 'linear-gradient(180deg, rgba(0,0,0,0.5), rgba(0,0,0,0.25))'
+ : 'transparent',
+ boxShadow: isActive ? 'inset 0 2px 4px rgba(0,0,0,0.55)' : undefined,
+ color: isActive ? theme.text : theme.label,
+ fontFamily: theme.fontUI,
+ fontSize: Math.max(10, size * 0.34),
+ fontWeight: 600,
+ letterSpacing: '0.07em',
+ textTransform: 'uppercase',
+ cursor: disabled ? 'not-allowed' : 'pointer',
+ transition: 'background 80ms, color 80ms',
+ outline: 'none',
+ }}
+ >
+ {led && (
+
+ )}
+ {opt}
+
+ );
+ })}
+ {name && }
+
+ );
+ },
+);
diff --git a/packages/dreamknob/src/skins/SteppedKnob.tsx b/packages/dreamknob/src/skins/SteppedKnob.tsx
index c8d7dc6..b07fed0 100644
--- a/packages/dreamknob/src/skins/SteppedKnob.tsx
+++ b/packages/dreamknob/src/skins/SteppedKnob.tsx
@@ -53,6 +53,7 @@ export const SteppedKnob = React.forwardRef(fu
min={min}
max={max}
steps={detents}
+ getAriaValueText={core.getAriaValueText ?? (positions ? (v: number) => positions[Math.round(((v - min) / (max - min || 1)) * (detents - 1))] ?? String(v) : format ? (v: number) => format(v) : undefined)}
>
{ctx => {
const index = Math.round(ctx.normalized * (detents - 1));
diff --git a/packages/dreamknob/src/skins/XYPad.tsx b/packages/dreamknob/src/skins/XYPad.tsx
index 71988a9..2ce37d8 100644
--- a/packages/dreamknob/src/skins/XYPad.tsx
+++ b/packages/dreamknob/src/skins/XYPad.tsx
@@ -1,5 +1,5 @@
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 type { ChangeMeta } from '../core/types';
@@ -8,6 +8,14 @@ export interface XYPadAxis {
max?: number;
step?: 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 {
@@ -32,6 +40,8 @@ export interface XYPadProps {
faceColor?: string;
/** Grid divisions per axis (0 disables). Default: 4. */
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. */
showValue?: boolean;
formatX?: (v: number) => string;
@@ -66,6 +76,7 @@ export const XYPad = React.forwardRef(function XYPad
color,
faceColor = 'rgba(255,255,255,0.04)',
grid = 4,
+ detentSize = 0.025,
showValue = true,
formatX,
formatY,
@@ -124,16 +135,39 @@ export const XYPad = React.forwardRef(function XYPad
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 s = session.current;
if (!s || s.rect.width <= 0 || s.rect.height <= 0) return;
- const nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
- const 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) });
+ let nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
+ let ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1);
+ 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 ny = ay.max === ay.min ? 0 : (pos.y - ay.min) / (ay.max - ay.min);
+ const nx = nFromValue(pos.x, ax);
+ const ny = nFromValue(pos.y, ay);
const hx = nx * width;
const hy = (1 - ny) * height;
@@ -153,12 +187,14 @@ export const XYPad = React.forwardRef(function XYPad
return (
@@ -238,10 +274,11 @@ export const XYPad = React.forwardRef(function XYPad
let handled = true;
const p = posRef.current;
switch (e.key) {
- case 'ArrowLeft': emit({ ...p, x: p.x - stepX }); break;
- case 'ArrowRight': emit({ ...p, x: p.x + stepX }); break;
- case 'ArrowUp': emit({ ...p, y: p.y + stepY }); break;
- case 'ArrowDown': emit({ ...p, y: p.y - stepY }); break;
+ // Arrows follow the VISUAL direction, so inverted axes negate.
+ case 'ArrowLeft': emit({ ...p, x: p.x - (ax.invert ? -stepX : stepX) }); break;
+ case 'ArrowRight': emit({ ...p, x: p.x + (ax.invert ? -stepX : stepX) }); 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;
}
if (handled) e.preventDefault();
@@ -262,7 +299,6 @@ export const XYPad = React.forwardRef(function XYPad
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair',
- opacity: disabled ? 0.45 : undefined,
outline: 'none',
borderRadius: 10,
...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),