diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx
index 66a8b50..fe2fb8a 100644
--- a/apps/docs/src/sections/ApiDocs.tsx
+++ b/apps/docs/src/sections/ApiDocs.tsx
@@ -50,6 +50,8 @@ export const ApiDocs: React.FC = () => (
+
+
@@ -59,6 +61,8 @@ export const ApiDocs: React.FC = () => (
+
+
@@ -66,8 +70,10 @@ export const ApiDocs: React.FC = () => (
@@ -89,6 +96,28 @@ export const ApiDocs: React.FC = () => (
above.
+
+ Every skin resolves its color and font defaults from the active theme; per-instance
+ props always win. Use base="light" for light backgrounds.
+
+
+
+`}
+ />
+
Compose your own knob
<Knob> wires up the interaction and provides a render context;
diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx
index c73a23e..27b144b 100644
--- a/apps/docs/src/sections/Gallery.tsx
+++ b/apps/docs/src/sections/Gallery.tsx
@@ -1,8 +1,10 @@
import React from 'react';
import {
Arc,
+ DreamknobProvider,
Fader,
FlatKnob,
+ ImageKnob,
Knob,
KnobValue,
LEDFader,
@@ -18,6 +20,60 @@ import {
logTaper,
} from 'dreamknob';
+/** Draw a film-strip of a simple hardware knob at runtime (stand-in for a KnobMan PNG). */
+const useGeneratedStrip = (frames = 31, fs = 120): string | null => {
+ const [src, setSrc] = React.useState(null);
+ React.useEffect(() => {
+ const canvas = document.createElement('canvas');
+ canvas.width = fs;
+ canvas.height = fs * frames;
+ const g = canvas.getContext('2d');
+ if (!g) return;
+ for (let i = 0; i < frames; i++) {
+ const cx = fs / 2;
+ const cy = i * fs + fs / 2;
+ const angle = ((225 + (i / (frames - 1)) * 270) * Math.PI) / 180;
+ const grad = g.createRadialGradient(cx - fs * 0.15, cy - fs * 0.18, fs * 0.05, cx, cy, fs * 0.45);
+ grad.addColorStop(0, '#5c5f69');
+ grad.addColorStop(0.65, '#26282e');
+ grad.addColorStop(1, '#0e0f12');
+ g.fillStyle = grad;
+ g.beginPath();
+ g.arc(cx, cy, fs * 0.4, 0, Math.PI * 2);
+ g.fill();
+ g.strokeStyle = 'rgba(0,0,0,0.65)';
+ g.lineWidth = fs * 0.025;
+ g.stroke();
+ g.strokeStyle = '#ffd23e';
+ g.lineWidth = fs * 0.05;
+ g.lineCap = 'round';
+ g.beginPath();
+ g.moveTo(cx + Math.sin(angle) * fs * 0.16, cy - Math.cos(angle) * fs * 0.16);
+ g.lineTo(cx + Math.sin(angle) * fs * 0.33, cy - Math.cos(angle) * fs * 0.33);
+ g.stroke();
+ }
+ setSrc(canvas.toDataURL('image/png'));
+ }, [frames, fs]);
+ return src;
+};
+
+const ImageKnobDemo: React.FC = () => {
+ const src = useGeneratedStrip();
+ if (!src) return null;
+ return (
+
+ );
+};
+
const Card: React.FC<{
title: string;
desc: string;
@@ -131,6 +187,88 @@ export const Gallery: React.FC = () => (
/>
+
+
+
+
+
+ `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
+ label="Gain"
+ aria-label="Bipolar gain demo"
+ />
+ `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
+ label="Trim"
+ aria-label="Bipolar fader demo"
+ />
+
+
+
+ (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
+ label="Freq"
+ aria-label="Editable demo"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/docs/src/sections/Playground.tsx b/apps/docs/src/sections/Playground.tsx
index 14133e0..00e73a2 100644
--- a/apps/docs/src/sections/Playground.tsx
+++ b/apps/docs/src/sections/Playground.tsx
@@ -51,6 +51,7 @@ interface Config {
label: string;
unit: string;
showValue: boolean;
+ editable: boolean;
arcFrom: 'min' | 'center';
}
@@ -67,6 +68,7 @@ const DEFAULTS: Config = {
label: 'Level',
unit: '',
showValue: true,
+ editable: false,
arcFrom: 'min',
};
@@ -86,6 +88,7 @@ const buildCode = (c: Config, controlled: boolean): string => {
props.push(`color="${c.color}"`);
if (c.label) props.push(`label="${c.label}"`);
if (c.unit) props.push(`unit="${c.unit}"`);
+ if (c.editable) props.push('editable');
if (c.skin === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
if (c.skin === 'stepped') props.push(`steps={5}`);
const name = SKINS[c.skin];
@@ -124,6 +127,7 @@ export const Playground: React.FC = () => {
label: cfg.label || undefined,
unit: cfg.unit || undefined,
showValue: cfg.showValue,
+ editable: cfg.editable,
'aria-label': cfg.label || 'playground knob',
};
@@ -260,6 +264,16 @@ export const Playground: React.FC = () => {
show value readout
+
+
+
diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md
index aeae998..06b7710 100644
--- a/packages/dreamknob/README.md
+++ b/packages/dreamknob/README.md
@@ -45,7 +45,8 @@ Every component shares one core:
| `↑ → / ↓ ←` | ± one step (`Shift` = 10×) |
| `PageUp/PageDown` | ± 10 % of range |
| `Home/End` | min / max |
-| Double-click | reset to `defaultValue` |
+| Double-click | reset to `defaultValue` — or open the type-in editor with `editable` |
+| `Escape` | cancel an in-flight drag, restoring the start value |
| Touch / pen | pointer capture, `touch-action: none` |
All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
@@ -59,6 +60,10 @@ All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
- `steps={5}` — evenly spaced detents (selector knobs).
- `taper` — `linearTaper` (default), `logTaper` (frequencies), `powTaper(n)` (gain),
or your own `{ toNormalized, fromNormalized }`.
+- `origin={0}` — bipolar anchor: arcs/fader fills draw from this value (pan, gain trim).
+- `detents={[0]}` + `detentSize` — magnetic snap points while dragging.
+- `editable` — double-click (knobs) or click the readout (faders) to type an exact
+ value; `parseValue` customizes parsing (`"1.2k"` → 1200 out of the box).
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks.
## Prebuilt skins
@@ -74,10 +79,24 @@ import {
SteppedKnob, // detented selector (positions={['LP','BP','HP']})
Fader, // linear channel fader, vertical or horizontal
LEDFader, // segmented LED meter-fader with color zones
+ ImageKnob, // film-strip sprite knob (KnobMan-style PNG strips)
SegmentDisplay, // standalone seven-segment numeric display
} from 'dreamknob'
```
+## Theming
+
+```tsx
+import { DreamknobProvider } from 'dreamknob'
+
+
+ {/* every control below inherits the tokens; instance props still win */}
+
+```
+
+`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
+`text`, `label`, `ticks`, `focusRing`, `fontMono`, `fontUI`.
+
Every skin takes the core props plus `size`, `label`, `showValue`, `unit`, `format`,
and per-part color props (`color`, `trackColor`, `faceColor`, `bodyColor`, …).
diff --git a/packages/dreamknob/src/components/Knob.tsx b/packages/dreamknob/src/components/Knob.tsx
index ffc6d6f..3938d14 100644
--- a/packages/dreamknob/src/components/Knob.tsx
+++ b/packages/dreamknob/src/components/Knob.tsx
@@ -1,16 +1,25 @@
import * as React from 'react';
import { KnobContextProvider, type KnobRenderContext } from '../core/context';
+import { useKnobTheme } from '../core/theme';
import type { KnobCoreProps } from '../core/types';
import { useKnob } from '../hooks/useKnob';
+import { ValueInput, defaultParseValue } from './ValueInput';
export interface KnobProps extends KnobCoreProps {
/** Square canvas size in px. Default: 80. */
size?: number;
/**
* Color of the keyboard focus ring, or `false` to disable it (only do this
- * if you render your own focus indicator). Default: a soft blue.
+ * if you render your own focus indicator). Default: the theme's ring.
*/
focusRing?: string | false;
+ /**
+ * Double-click opens a type-in editor instead of resetting: type an exact
+ * value, Enter/blur commits, Escape cancels. Accepts "1.2k" style input.
+ */
+ editable?: boolean;
+ /** Custom parser for typed input, e.g. to accept "-6 dB" or "A4". */
+ parseValue?: (text: string) => number | null;
className?: string;
style?: React.CSSProperties;
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
@@ -26,17 +35,35 @@ export interface KnobProps extends KnobCoreProps {
*/
export const Knob: React.FC
= ({
size = 80,
- focusRing = 'rgba(130,180,255,0.65)',
+ focusRing,
+ editable = false,
+ parseValue,
className,
style,
children,
overlay,
...core
}) => {
+ const theme = useKnobTheme();
+ const ring = focusRing ?? theme.focusRing;
const knob = useKnob(core);
+ const [editing, setEditing] = React.useState(false);
+
+ const closeEditor = () => {
+ setEditing(false);
+ knob.ref.current?.focus({ preventScroll: true });
+ };
+ const onDoubleClick = editable
+ ? (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setEditing(true);
+ }
+ : knob.bind.onDoubleClick;
+
const ctx: KnobRenderContext = {
value: knob.value,
normalized: knob.normalized,
+ originNormalized: knob.originNormalized,
angle: knob.angle,
isDragging: knob.isDragging,
min: knob.min,
@@ -46,12 +73,14 @@ export const Knob: React.FC = ({
angleRange: knob.angleRange,
size,
center: size / 2,
+ setValue: knob.setValue,
};
return (
= ({
width: size,
height: size,
borderRadius: '50%',
- ...(knob.isFocusVisible && focusRing
- ? { boxShadow: `0 0 0 2px ${focusRing}` }
- : undefined),
+ ...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
...knob.bind.style,
...style,
}}
@@ -76,6 +103,17 @@ export const Knob: React.FC = ({
{typeof children === 'function' ? children(ctx) : children}
{typeof overlay === 'function' ? overlay(ctx) : overlay}
+ {editing && (
+ {
+ const parsed = (parseValue ?? defaultParseValue)(raw);
+ if (parsed !== null && Number.isFinite(parsed)) knob.setValue(parsed);
+ closeEditor();
+ }}
+ onCancel={closeEditor}
+ />
+ )}
);
diff --git a/packages/dreamknob/src/components/ValueInput.tsx b/packages/dreamknob/src/components/ValueInput.tsx
new file mode 100644
index 0000000..51a2cbf
--- /dev/null
+++ b/packages/dreamknob/src/components/ValueInput.tsx
@@ -0,0 +1,88 @@
+import * as React from 'react';
+import { useKnobTheme } from '../core/theme';
+
+/**
+ * Parse a typed value: accepts "42", "-6.5", ".5", comma decimals ("3,5"),
+ * a k-suffix ("1.2k" -> 1200), and ignores trailing units ("440 Hz").
+ * Returns null when no number can be read.
+ */
+export const defaultParseValue = (s: string): number | null => {
+ const m = s
+ .trim()
+ .replace(',', '.')
+ .match(/^([-+]?(?:\d+\.?\d*|\.\d+))\s*([kK])?/);
+ if (!m) return null;
+ const n = parseFloat(m[1]) * (m[2] ? 1000 : 1);
+ return Number.isFinite(n) ? n : null;
+};
+
+export interface ValueInputProps {
+ initial: string;
+ onCommit: (raw: string) => void;
+ onCancel: () => void;
+ style?: React.CSSProperties;
+}
+
+/**
+ * The type-in editor: commits on Enter or blur, cancels on Escape. Stops
+ * pointer/keyboard events from reaching the knob underneath.
+ */
+export const ValueInput: React.FC = ({
+ initial,
+ onCommit,
+ onCancel,
+ style,
+}) => {
+ const theme = useKnobTheme();
+ const ref = React.useRef(null);
+ const settled = React.useRef(false);
+
+ React.useEffect(() => {
+ ref.current?.select();
+ }, []);
+
+ const commit = (raw: string) => {
+ if (settled.current) return;
+ settled.current = true;
+ onCommit(raw);
+ };
+
+ return (
+ e.stopPropagation()}
+ onDoubleClick={e => e.stopPropagation()}
+ onKeyDown={e => {
+ e.stopPropagation();
+ if (e.key === 'Enter') commit(e.currentTarget.value);
+ else if (e.key === 'Escape') {
+ settled.current = true;
+ onCancel();
+ }
+ }}
+ onBlur={e => commit(e.currentTarget.value)}
+ style={{
+ position: 'absolute',
+ left: '50%',
+ top: '50%',
+ transform: 'translate(-50%, -50%)',
+ width: '72%',
+ boxSizing: 'border-box',
+ background: 'rgba(8, 9, 12, 0.92)',
+ color: theme.text,
+ border: `1px solid ${theme.focusRing}`,
+ borderRadius: 6,
+ padding: '3px 6px',
+ fontFamily: theme.fontMono,
+ fontSize: 13,
+ textAlign: 'center',
+ outline: 'none',
+ zIndex: 1,
+ ...style,
+ }}
+ />
+ );
+};
diff --git a/packages/dreamknob/src/core/context.ts b/packages/dreamknob/src/core/context.ts
index 744dead..f4afaa6 100644
--- a/packages/dreamknob/src/core/context.ts
+++ b/packages/dreamknob/src/core/context.ts
@@ -6,6 +6,8 @@ export interface KnobRenderContext extends KnobState {
size: number;
/** Center coordinate (size / 2). */
center: number;
+ /** Imperatively set the value (snapped + clamped) — for editors and custom UI. */
+ setValue: (value: number) => void;
}
const Ctx = React.createContext(null);
diff --git a/packages/dreamknob/src/core/math.test.ts b/packages/dreamknob/src/core/math.test.ts
index 9fdb1b0..43096fb 100644
--- a/packages/dreamknob/src/core/math.test.ts
+++ b/packages/dreamknob/src/core/math.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
angleFromNormalized,
angleFromPoint,
+ applyDetents,
clamp,
decimalsFromStep,
describeArc,
@@ -128,6 +129,22 @@ describe('angles', () => {
});
});
+describe('applyDetents', () => {
+ it('snaps within the radius and releases outside it', () => {
+ expect(applyDetents(0.51, [0.5], 0.025)).toBe(0.5);
+ expect(applyDetents(0.49, [0.5], 0.025)).toBe(0.5);
+ expect(applyDetents(0.54, [0.5], 0.025)).toBe(0.54);
+ expect(applyDetents(0.2, [0.5], 0.025)).toBe(0.2);
+ });
+ it('picks the nearest of several detents', () => {
+ expect(applyDetents(0.26, [0.25, 0.5, 0.75], 0.05)).toBe(0.25);
+ expect(applyDetents(0.72, [0.25, 0.5, 0.75], 0.05)).toBe(0.75);
+ });
+ it('passes through with no detents', () => {
+ expect(applyDetents(0.42, [], 0.05)).toBe(0.42);
+ });
+});
+
describe('describeArc', () => {
it('produces a drawable path', () => {
const d = describeArc(50, 50, 40, 225, 495);
diff --git a/packages/dreamknob/src/core/math.ts b/packages/dreamknob/src/core/math.ts
index a6ef03f..f5336bf 100644
--- a/packages/dreamknob/src/core/math.ts
+++ b/packages/dreamknob/src/core/math.ts
@@ -51,6 +51,27 @@ export const findClosest = (values: readonly number[], value: number): number =>
return best;
};
+/**
+ * Magnetic detents: snap `n` to the nearest of `detents` (all in normalized
+ * [0, 1] space) when within `radius` of it; otherwise pass `n` through.
+ */
+export const applyDetents = (
+ n: number,
+ detents: readonly number[],
+ radius: number,
+): number => {
+ let best = n;
+ let bestDelta = radius;
+ for (const d of detents) {
+ const delta = Math.abs(n - d);
+ if (delta <= bestDelta) {
+ best = d;
+ bestDelta = delta;
+ }
+ }
+ return best;
+};
+
// ---------------------------------------------------------------------------
// Tapers — map between value space and the normalized [0, 1] travel of the
// control. Audio parameters are often logarithmic (frequency) or power-law
diff --git a/packages/dreamknob/src/core/theme.tsx b/packages/dreamknob/src/core/theme.tsx
new file mode 100644
index 0000000..773850f
--- /dev/null
+++ b/packages/dreamknob/src/core/theme.tsx
@@ -0,0 +1,81 @@
+import * as React from 'react';
+
+export const MONO_FONT =
+ "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
+export const UI_FONT =
+ "'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
+
+/**
+ * Shared design tokens. Every prebuilt skin resolves its color/font defaults
+ * from the active theme, so a single provider can restyle a whole console;
+ * per-instance props always win over the theme.
+ */
+export interface KnobTheme {
+ /**
+ * Accent for value arcs, pointers and LEDs. Optional — when unset, each
+ * skin keeps its own signature default color.
+ */
+ accent?: string;
+ /** Background tracks and rails. */
+ track: string;
+ /** Knob face/body fill for flat skins. */
+ face: string;
+ /** Value readouts. */
+ text: string;
+ /** Captions / parameter names. */
+ label: string;
+ /** Inactive tick marks and printed scales. */
+ ticks: string;
+ /** Keyboard focus ring. */
+ focusRing: string;
+ fontMono: string;
+ fontUI: string;
+}
+
+export const darkTheme: KnobTheme = {
+ track: 'rgba(255,255,255,0.12)',
+ face: 'rgba(255,255,255,0.05)',
+ text: 'rgba(255,255,255,0.92)',
+ label: 'rgba(255,255,255,0.45)',
+ ticks: 'rgba(255,255,255,0.18)',
+ focusRing: 'rgba(130,180,255,0.65)',
+ fontMono: MONO_FONT,
+ fontUI: UI_FONT,
+};
+
+export const lightTheme: KnobTheme = {
+ track: 'rgba(0,0,0,0.14)',
+ face: 'rgba(0,0,0,0.06)',
+ text: 'rgba(0,0,0,0.85)',
+ label: 'rgba(0,0,0,0.5)',
+ ticks: 'rgba(0,0,0,0.28)',
+ focusRing: 'rgba(0,110,220,0.55)',
+ fontMono: MONO_FONT,
+ fontUI: UI_FONT,
+};
+
+const ThemeCtx = React.createContext(darkTheme);
+
+export interface DreamknobProviderProps {
+ /** Base token set to start from. Default: 'dark'. */
+ base?: 'dark' | 'light';
+ /** Token overrides merged over the base. */
+ theme?: Partial;
+ children?: React.ReactNode;
+}
+
+/** Provides shared design tokens to every dreamknob control below it. */
+export const DreamknobProvider: React.FC = ({
+ base = 'dark',
+ theme,
+ children,
+}) => {
+ const value = React.useMemo(
+ () => ({ ...(base === 'light' ? lightTheme : darkTheme), ...theme }),
+ [base, theme],
+ );
+ return {children};
+};
+
+/** The active theme (dark tokens when no provider is present). */
+export const useKnobTheme = (): KnobTheme => React.useContext(ThemeCtx);
diff --git a/packages/dreamknob/src/core/types.ts b/packages/dreamknob/src/core/types.ts
index cdb1584..0f8cc3a 100644
--- a/packages/dreamknob/src/core/types.ts
+++ b/packages/dreamknob/src/core/types.ts
@@ -37,6 +37,20 @@ export interface KnobCoreProps {
steps?: number;
/** Travel-to-value curve. Defaults to linear. Use `logTaper` / `powTaper(n)` for audio params. */
taper?: Taper;
+ /**
+ * Anchor value for bipolar controls: arcs and fader fills draw from here
+ * instead of from the minimum (e.g. `origin={0}` on a -60..+12 dB gain or a
+ * -50..50 pan). Exposed to custom skins as `originNormalized`.
+ */
+ origin?: number;
+ /**
+ * Magnetic detent values: while dragging, the control snaps to the nearest
+ * of these when within `detentSize` of it (e.g. `detents={[0]}` for a
+ * center-detented pan). Wheel and keyboard are unaffected.
+ */
+ detents?: readonly number[];
+ /** Detent capture radius as a fraction of travel. Default: 0.025. */
+ detentSize?: number;
/** Drag behaviour. Default: 'rotary'. */
interaction?: InteractionMode;
/** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */
@@ -77,6 +91,8 @@ export interface KnobState {
value: number;
/** Normalized travel position in [0, 1] (taper space). */
normalized: number;
+ /** Normalized position of `origin`, when set — the bipolar fill anchor. */
+ originNormalized?: number;
/** Display angle in degrees clockwise from 12 o'clock. */
angle: number;
isDragging: boolean;
diff --git a/packages/dreamknob/src/hooks/useKnob.ts b/packages/dreamknob/src/hooks/useKnob.ts
index 7a88809..69804d4 100644
--- a/packages/dreamknob/src/hooks/useKnob.ts
+++ b/packages/dreamknob/src/hooks/useKnob.ts
@@ -2,6 +2,7 @@ import * as React from 'react';
import {
angleFromNormalized,
angleFromPoint,
+ applyDetents,
clamp,
decimalsFromStep,
findClosest,
@@ -75,6 +76,9 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
interaction = 'rotary',
dragSensitivity = 200,
trackInset = 0,
+ origin,
+ detents,
+ detentSize = 0.025,
fineMultiplier = 0.1,
angleOffset = 225,
angleRange = 270,
@@ -153,6 +157,18 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
[emit, taper, min, max],
);
+ // Detent positions in normalized space, applied only during pointer drags.
+ const detentsN = React.useMemo(
+ () =>
+ detents?.map(d => clamp(taper.toNormalized(clamp(d, min, max), min, max), 0, 1)),
+ [detents, taper, min, max],
+ );
+ const setFromDrag = React.useCallback(
+ (n: number) =>
+ setFromNormalized(detentsN ? applyDetents(n, detentsN, detentSize) : n),
+ [setFromNormalized, detentsN, detentSize],
+ );
+
// -------------------------------------------------------------------------
// Pointer dragging
// -------------------------------------------------------------------------
@@ -166,19 +182,19 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
switch (interaction) {
case 'rotary': {
const angle = angleFromPoint(clientX, clientY, s.centerX, s.centerY);
- setFromNormalized(normalizedFromAngle(angle, angleOffset % 360, angleRange));
+ setFromDrag(normalizedFromAngle(angle, angleOffset % 360, angleRange));
break;
}
case 'track-vertical': {
const travel = s.rect.height - trackInset * 2;
if (travel <= 0) break;
- setFromNormalized((s.rect.bottom - trackInset - clientY) / travel);
+ setFromDrag((s.rect.bottom - trackInset - clientY) / travel);
break;
}
case 'track-horizontal': {
const travel = s.rect.width - trackInset * 2;
if (travel <= 0) break;
- setFromNormalized((clientX - s.rect.left - trackInset) / travel);
+ setFromDrag((clientX - s.rect.left - trackInset) / travel);
break;
}
default: {
@@ -188,14 +204,15 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
dn += (s.lastY - clientY) * scale;
if (interaction === 'horizontal' || interaction === 'both')
dn += (clientX - s.lastX) * scale;
+ // s.n stays continuous so magnetic detents release cleanly.
s.n = clamp(s.n + dn, 0, 1);
- setFromNormalized(s.n);
+ setFromDrag(s.n);
}
}
s.lastX = clientX;
s.lastY = clientY;
},
- [interaction, angleOffset, angleRange, dragSensitivity, trackInset, fineMultiplier, setFromNormalized],
+ [interaction, angleOffset, angleRange, dragSensitivity, trackInset, fineMultiplier, setFromDrag],
);
const onPointerDown = React.useCallback(
@@ -409,10 +426,15 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
+ const originNormalized =
+ origin === undefined
+ ? undefined
+ : clamp(taper.toNormalized(clamp(origin, min, max), min, max), 0, 1);
return {
value,
normalized,
+ originNormalized,
angle,
isDragging,
isFocusVisible,
diff --git a/packages/dreamknob/src/index.ts b/packages/dreamknob/src/index.ts
index 480da08..66b4d5c 100644
--- a/packages/dreamknob/src/index.ts
+++ b/packages/dreamknob/src/index.ts
@@ -2,6 +2,16 @@
export { useKnob, type UseKnobResult } from './hooks/useKnob';
export { Knob, type KnobProps } from './components/Knob';
export { useKnobContext, type KnobRenderContext } from './core/context';
+export {
+ DreamknobProvider,
+ useKnobTheme,
+ darkTheme,
+ lightTheme,
+ MONO_FONT,
+ UI_FONT,
+ type KnobTheme,
+ type DreamknobProviderProps,
+} from './core/theme';
export type { KnobCoreProps, KnobState, InteractionMode } from './core/types';
export {
clamp,
@@ -9,6 +19,7 @@ export {
snapToStep,
decimalsFromStep,
findClosest,
+ applyDetents,
linearTaper,
logTaper,
powTaper,
@@ -31,6 +42,9 @@ export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from '
// Digital
export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay';
+// Value editing
+export { defaultParseValue, ValueInput, type ValueInputProps } from './components/ValueInput';
+
// Prebuilt skins
export type { SkinProps } from './skins/shared';
export { FlatKnob, type FlatKnobProps } from './skins/FlatKnob';
@@ -42,4 +56,5 @@ export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
export { Fader, type FaderProps } from './skins/Fader';
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
+export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
diff --git a/packages/dreamknob/src/primitives/Arc.tsx b/packages/dreamknob/src/primitives/Arc.tsx
index 9d49a32..bdaa659 100644
--- a/packages/dreamknob/src/primitives/Arc.tsx
+++ b/packages/dreamknob/src/primitives/Arc.tsx
@@ -10,7 +10,11 @@ export interface ArcProps {
color?: string;
/** Background track color. Omit to skip the track. */
trackColor?: string;
- /** Draw the value arc from the minimum ('min') or from the travel center ('center', pan-style). */
+ /**
+ * Draw the value arc from the minimum ('min') or from the anchor ('center',
+ * pan-style). The anchor is the knob's `origin` value when set, otherwise
+ * the travel midpoint.
+ */
from?: 'min' | 'center';
cap?: 'butt' | 'round';
/** Extra SVG props for the value arc path (e.g. filter for glow). */
@@ -29,16 +33,17 @@ export const Arc: React.FC = ({
arcProps,
opacity,
}) => {
- const { size, center, normalized, angleOffset, angleRange } = useKnobContext();
+ const { size, center, normalized, originNormalized, angleOffset, angleRange } =
+ useKnobContext();
const r = radius ?? (size - thickness) / 2;
let start: number;
let end: number;
if (from === 'center') {
- const mid = angleOffset + angleRange / 2;
+ const anchor = angleOffset + (originNormalized ?? 0.5) * angleRange;
const now = angleOffset + normalized * angleRange;
- start = Math.min(mid, now);
- end = Math.max(mid, now);
+ start = Math.min(anchor, now);
+ end = Math.max(anchor, now);
} else {
start = angleOffset;
end = angleOffset + normalized * angleRange;
diff --git a/packages/dreamknob/src/skins/Fader.tsx b/packages/dreamknob/src/skins/Fader.tsx
index 18b9c86..657fccb 100644
--- a/packages/dreamknob/src/skins/Fader.tsx
+++ b/packages/dreamknob/src/skins/Fader.tsx
@@ -1,7 +1,8 @@
import * as React from 'react';
import { useKnob } from '../hooks/useKnob';
import type { KnobCoreProps } from '../core/types';
-import { MONO_FONT, UI_FONT } from './shared';
+import { useKnobTheme } from '../core/theme';
+import { ValueInput, defaultParseValue } from '../components/ValueInput';
export interface FaderProps extends Omit {
orientation?: 'vertical' | 'horizontal';
@@ -23,6 +24,10 @@ export interface FaderProps extends Omit {
format?: (value: number) => string;
textColor?: string;
labelColor?: string;
+ /** Click the readout to type an exact value. */
+ editable?: boolean;
+ /** Custom parser for typed input. */
+ parseValue?: (text: string) => number | null;
className?: string;
style?: React.CSSProperties;
}
@@ -30,24 +35,32 @@ export interface FaderProps extends Omit {
/** Studio channel fader: absolute-position linear control with a cap handle. */
export const Fader: React.FC = ({
orientation = 'vertical',
- focusRing = 'rgba(130,180,255,0.65)',
+ focusRing,
length = 160,
breadth = 44,
- color = '#4cc2ff',
- trackColor = 'rgba(255,255,255,0.12)',
+ color: colorProp,
+ trackColor,
showFill = true,
tickCount = 9,
- tickColor = 'rgba(255,255,255,0.15)',
+ tickColor,
label,
showValue = true,
unit,
format,
- textColor = 'rgba(255,255,255,0.92)',
- labelColor = 'rgba(255,255,255,0.45)',
+ textColor,
+ labelColor,
+ editable = false,
+ parseValue,
className,
style,
...core
}) => {
+ const [editing, setEditing] = React.useState(false);
+ const theme = useKnobTheme();
+ const color = colorProp ?? theme.accent ?? '#4cc2ff';
+ const ring = focusRing ?? theme.focusRing;
+ const resolvedTrack = trackColor ?? theme.track;
+ const resolvedTicks = tickColor ?? theme.ticks;
const vertical = orientation === 'vertical';
const capMain = 20; // handle size along the travel axis
const knob = useKnob({
@@ -67,9 +80,13 @@ export const Fader: React.FC = ({
// Handle center position along the travel axis.
const travel = length - capMain;
- const pos = vertical
- ? length - capMain / 2 - knob.normalized * travel
- : capMain / 2 + knob.normalized * travel;
+ const toPos = (n: number) =>
+ vertical ? length - capMain / 2 - n * travel : capMain / 2 + n * travel;
+ const pos = toPos(knob.normalized);
+ // Bipolar controls fill from the origin; unipolar from the start of travel.
+ const originPos = toPos(knob.originNormalized ?? 0);
+ const fillStart = Math.min(pos, originPos);
+ const fillLength = Math.abs(pos - originPos);
const fixed = knob.value.toFixed(knob.decimals);
const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
@@ -82,13 +99,13 @@ export const Fader: React.FC = ({
ticks.push(
vertical ? (
-
-
+
+
) : (
-
-
+
+
),
);
@@ -109,19 +126,43 @@ export const Fader: React.FC = ({
}}
>
{showValue && (
-
-
- {text}
-
+
+ {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}
+
+ )}
)}
= ({
height: h,
display: 'inline-flex',
borderRadius: 8,
- ...(knob.isFocusVisible && focusRing
- ? { boxShadow: `0 0 0 2px ${focusRing}` }
- : undefined),
+ ...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
}}
>