From 804a85cca6cfcda3efcf891f412903edf5386b70 Mon Sep 17 00:00:00 2001 From: Dreamodus Date: Sun, 12 Jul 2026 15:05:46 -0700 Subject: [PATCH] Tier 1: origin anchor, detents, theming, type-in editing, ImageKnob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - origin: bipolar anchor value — Arc from=center, Fader fill and LEDFader segments draw from origin instead of min/mid-travel - detents + detentSize: magnetic snap while dragging (normalized space, wheel/keyboard unaffected); applyDetents exported - DreamknobProvider + KnobTheme: accent/track/face/text/label/ticks/ focusRing/fonts tokens with dark and light bases; every skin resolves defaults from the theme, instance props win - editable + parseValue: double-click knobs (or click fader readouts) to type exact values; Enter/blur commits, Esc cancels, 1.2k parses; ValueInput + defaultParseValue exported; setValue added to context - ImageKnob: film-strip sprite knob (KnobMan-style strips), vertical or horizontal, with focus ring, editing, value/label - Docs: LED gallery cards for film-strip/bipolar/editing/theming, playground editable toggle, theming API section, README updates --- apps/docs/src/sections/ApiDocs.tsx | 33 +++- apps/docs/src/sections/Gallery.tsx | 138 +++++++++++++++ apps/docs/src/sections/Playground.tsx | 14 ++ packages/dreamknob/README.md | 21 ++- packages/dreamknob/src/components/Knob.tsx | 48 ++++- .../dreamknob/src/components/ValueInput.tsx | 88 ++++++++++ packages/dreamknob/src/core/context.ts | 2 + packages/dreamknob/src/core/math.test.ts | 17 ++ packages/dreamknob/src/core/math.ts | 21 +++ packages/dreamknob/src/core/theme.tsx | 81 +++++++++ packages/dreamknob/src/core/types.ts | 16 ++ packages/dreamknob/src/hooks/useKnob.ts | 32 +++- packages/dreamknob/src/index.ts | 15 ++ packages/dreamknob/src/primitives/Arc.tsx | 15 +- packages/dreamknob/src/skins/Fader.tsx | 115 ++++++++---- packages/dreamknob/src/skins/FlatKnob.tsx | 48 +++-- packages/dreamknob/src/skins/ImageKnob.tsx | 164 ++++++++++++++++++ packages/dreamknob/src/skins/LEDFader.tsx | 80 ++++++--- packages/dreamknob/src/skins/LEDKnob.tsx | 19 +- packages/dreamknob/src/skins/MetalKnob.tsx | 25 ++- packages/dreamknob/src/skins/NeonKnob.tsx | 21 ++- packages/dreamknob/src/skins/RubberKnob.tsx | 29 ++-- packages/dreamknob/src/skins/SteppedKnob.tsx | 38 ++-- packages/dreamknob/src/skins/VintageKnob.tsx | 65 ++++--- packages/dreamknob/src/skins/shared.ts | 16 +- 25 files changed, 999 insertions(+), 162 deletions(-) create mode 100644 packages/dreamknob/src/components/ValueInput.tsx create mode 100644 packages/dreamknob/src/core/theme.tsx create mode 100644 packages/dreamknob/src/skins/ImageKnob.tsx 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 = () => (

↑/→ and ↓/← step · Shift+arrows jump 10× · PageUp/PageDown move 10% · Home/End jump to min/max · - scroll wheel nudges (Shift = fine) · double-click resets · touch works via pointer - capture. + Esc cancels a drag and restores the start value · scroll wheel nudges + (Shift = fine) · double-click resets (or opens the editor when{' '} + editable) · touch works via pointer capture · every control shows a + keyboard-only focus ring.

Prebuilt skins

@@ -81,6 +87,7 @@ export const ApiDocs: React.FC = () => ( +

@@ -89,6 +96,28 @@ export const ApiDocs: React.FC = () => ( above.

+

Theming

+

+ 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), }} >
= ({ size = 80, - color = '#4cc2ff', - trackColor = 'rgba(255,255,255,0.12)', - faceColor = 'rgba(255,255,255,0.05)', + color, + trackColor, + faceColor, pointerColor, - textColor = 'rgba(255,255,255,0.92)', - labelColor = 'rgba(255,255,255,0.45)', + textColor, + labelColor, arcFrom = 'min', arcThickness, label, showValue = true, unit, format, + focusRing, className, style, ...core }) => { + const theme = useKnobTheme(); + const accent = color ?? theme.accent ?? '#4cc2ff'; const t = arcThickness ?? Math.max(3, size * 0.055); return ( - - - + + + {showValue && ( )} {label && ( - + {label} )} diff --git a/packages/dreamknob/src/skins/ImageKnob.tsx b/packages/dreamknob/src/skins/ImageKnob.tsx new file mode 100644 index 0000000..eecfddc --- /dev/null +++ b/packages/dreamknob/src/skins/ImageKnob.tsx @@ -0,0 +1,164 @@ +import * as React from 'react'; +import { useKnob } from '../hooks/useKnob'; +import { useKnobTheme } from '../core/theme'; +import type { KnobCoreProps } from '../core/types'; +import { ValueInput, defaultParseValue } from '../components/ValueInput'; + +export interface ImageKnobProps extends KnobCoreProps { + /** Film-strip image: every frame is one rotation step, stacked in a strip. */ + src: string; + /** Number of frames in the strip. */ + frames: number; + /** How the frames are stacked in the image. Default: 'vertical'. */ + stripOrientation?: 'vertical' | 'horizontal'; + /** Rendered frame size in px (frames are assumed square). Default: 90. */ + size?: number; + label?: string; + showValue?: boolean; + unit?: string; + format?: (value: number) => string; + textColor?: string; + labelColor?: string; + /** Keyboard focus ring color, or false to disable. */ + focusRing?: string | false; + /** Double-click to type an exact value. */ + editable?: boolean; + parseValue?: (text: string) => number | null; + /** Extra styles for the image layer (e.g. filter, borderRadius). */ + imageStyle?: React.CSSProperties; + className?: string; + style?: React.CSSProperties; +} + +/** + * Film-strip knob: renders one frame of a sprite strip per value position — + * the classic KnobMan / JUCE workflow for photoreal hardware knobs. + */ +export const ImageKnob: React.FC = ({ + src, + frames, + stripOrientation = 'vertical', + size = 90, + label, + showValue = false, + unit, + format, + textColor, + labelColor, + focusRing, + editable = false, + parseValue, + imageStyle, + className, + style, + ...core +}) => { + const theme = useKnobTheme(); + const knob = useKnob(core); + const [editing, setEditing] = React.useState(false); + const ring = focusRing ?? theme.focusRing; + + const frame = frames > 1 ? Math.round(knob.normalized * (frames - 1)) : 0; + const p = frames > 1 ? (frame / (frames - 1)) * 100 : 0; + const vertical = stripOrientation === 'vertical'; + + const fixed = knob.value.toFixed(knob.decimals); + const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed; + const text = format ? format(knob.value) : `${trimmed}${unit ?? ''}`; + + const closeEditor = () => { + setEditing(false); + knob.ref.current?.focus({ preventScroll: true }); + }; + + return ( +
+
{ + e.stopPropagation(); + setEditing(true); + } + : knob.bind.onDoubleClick + } + style={{ + ...knob.bind.style, + position: 'relative', + width: size, + height: size, + borderRadius: '50%', + ...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined), + }} + > + + {showValue && ( +
+ + {text} + +
+ )} + {label && ( +
+ + {label} + +
+ )} +
+ ); +}; diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx index 5d99e96..9f74c90 100644 --- a/packages/dreamknob/src/skins/LEDFader.tsx +++ b/packages/dreamknob/src/skins/LEDFader.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import { useKnob } from '../hooks/useKnob'; import type { KnobCoreProps } from '../core/types'; +import { useKnobTheme } from '../core/theme'; import { SegmentDisplay } from '../digital/SegmentDisplay'; -import { UI_FONT } from './shared'; +import { ValueInput, defaultParseValue } from '../components/ValueInput'; export interface LEDFaderZone { /** Upper bound of the zone as a normalized position (0..1]. */ @@ -40,6 +41,10 @@ export interface LEDFaderProps extends Omit { /** Decimals in the readout (defaults to the control's decimals, capped at 1). */ displayDecimals?: number; 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; } @@ -50,21 +55,27 @@ export const LEDFader: React.FC = ({ length = 160, breadth = 34, segments = 24, - color = '#3df2ad', + color: colorProp, zones, offOpacity = 0.13, glow = true, faceColor = '#0b0d0e', - focusRing = 'rgba(130,180,255,0.65)', + focusRing, label, showValue = true, digits = 4, displayDecimals, - labelColor = 'rgba(255,255,255,0.45)', + labelColor, + editable = false, + parseValue, className, style, ...core }) => { + const [editing, setEditing] = React.useState(false); + const theme = useKnobTheme(); + const color = colorProp ?? theme.accent ?? '#3df2ad'; + const ring = focusRing ?? theme.focusRing; const vertical = orientation === 'vertical'; const pad = 5; const knob = useKnob({ @@ -84,14 +95,17 @@ export const LEDFader: React.FC = ({ const zoneFor = (t: number): string => (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; - const lit = Math.round(knob.normalized * segments); + // Bipolar controls light the segments between the origin and the value. + const originN = knob.originNormalized ?? 0; + const litFrom = Math.round(Math.min(knob.normalized, originN) * segments); + const litTo = Math.round(Math.max(knob.normalized, originN) * segments); const slot = (length - pad * 2) / segments; const gap = Math.min(3, slot * 0.35); const cross = breadth - pad * 2; const leds: React.ReactNode[] = []; for (let i = 0; i < segments; i++) { - const on = i < lit; + const on = i >= litFrom && i < litTo; const c = zoneFor((i + 1) / segments); const common = { rx: 1.5, @@ -135,16 +149,42 @@ export const LEDFader: React.FC = ({ }} > {showValue && ( -
- +
setEditing(true) : undefined} + title={editable ? 'Click to type a value' : 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 }} + /> + ) : ( + + )}
)}
= ({ height: h, display: 'inline-flex', borderRadius: 6, - ...(knob.isFocusVisible && focusRing - ? { boxShadow: `0 0 0 2px ${focusRing}` } - : undefined), + ...(knob.isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined), }} >
= ({ size = 90, - color = '#3df2ad', + color: colorProp, offColor = 'rgba(255,255,255,0.07)', segments = 24, digits = 3, displayDecimals, - labelColor = 'rgba(255,255,255,0.45)', + labelColor, faceColor = '#0b0d0e', label, + focusRing, className, style, ...core }) => { + const theme = useKnobTheme(); + const color = colorProp ?? theme.accent ?? '#3df2ad'; const id = React.useId(); const glowId = `${id}-glow`; return ( - + {ctx => ( <> @@ -120,7 +124,12 @@ export const LEDKnob: React.FC = ({ glowId={glowId} /> {label && ( - + {label} )} diff --git a/packages/dreamknob/src/skins/MetalKnob.tsx b/packages/dreamknob/src/skins/MetalKnob.tsx index d97d9c3..2bed5fa 100644 --- a/packages/dreamknob/src/skins/MetalKnob.tsx +++ b/packages/dreamknob/src/skins/MetalKnob.tsx @@ -4,7 +4,8 @@ import { Arc } from '../primitives/Arc'; import { Pointer } from '../primitives/Pointer'; import { Ticks } from '../primitives/Ticks'; import { KnobLabel, KnobValue } from '../primitives/Text'; -import { MONO_FONT, UI_FONT, type SkinProps } from './shared'; +import { useKnobTheme } from '../core/theme'; +import type { SkinProps } from './shared'; export interface MetalKnobProps extends SkinProps { /** Accent color for the active ticks / value arc. */ @@ -23,10 +24,10 @@ export interface MetalKnobProps extends SkinProps { /** 3D brushed-aluminum knob with a knurled rim — classic hi-fi hardware. */ export const MetalKnob: React.FC = ({ size = 90, - color = '#4cc2ff', + color: colorProp, tone = 'silver', - tickColor = 'rgba(255,255,255,0.18)', - labelColor = 'rgba(255,255,255,0.45)', + tickColor, + labelColor, indicatorColor, textColor, showArc = true, @@ -35,10 +36,13 @@ export const MetalKnob: React.FC = ({ showValue = false, unit, format, + focusRing, className, style, ...core }) => { + const theme = useKnobTheme(); + const color = colorProp ?? theme.accent ?? '#4cc2ff'; const id = React.useId(); const rimId = `${id}-rim`; const faceId = `${id}-face`; @@ -57,7 +61,7 @@ export const MetalKnob: React.FC = ({ const indicator = indicatorColor ?? (silver ? '#1b1c21' : color); return ( - + @@ -94,7 +98,7 @@ export const MetalKnob: React.FC = ({ radius={size / 2 - size * 0.055} length={size * 0.05} width={Math.max(1, size * 0.015)} - color={tickColor} + color={tickColor ?? theme.ticks} activeColor={color} /> @@ -124,12 +128,17 @@ export const MetalKnob: React.FC = ({ unit={unit} format={format} fontSize={size * 0.13} - fontFamily={MONO_FONT} + fontFamily={theme.fontMono} dy={size * 0.09} /> )} {label && ( - + {label} )} diff --git a/packages/dreamknob/src/skins/NeonKnob.tsx b/packages/dreamknob/src/skins/NeonKnob.tsx index a8f2a46..575d9f2 100644 --- a/packages/dreamknob/src/skins/NeonKnob.tsx +++ b/packages/dreamknob/src/skins/NeonKnob.tsx @@ -3,7 +3,8 @@ import { Knob } from '../components/Knob'; import { Arc } from '../primitives/Arc'; import { Pointer } from '../primitives/Pointer'; import { KnobLabel, KnobValue } from '../primitives/Text'; -import { MONO_FONT, UI_FONT, type SkinProps } from './shared'; +import { useKnobTheme } from '../core/theme'; +import type { SkinProps } from './shared'; export interface NeonKnobProps extends SkinProps { color?: string; @@ -16,23 +17,26 @@ export interface NeonKnobProps extends SkinProps { /** Minimal glowing arc knob — futuristic / dark-mode DAW style. */ export const NeonKnob: React.FC = ({ size = 84, - color = '#e44cff', + color: colorProp, trackColor = 'rgba(255,255,255,0.06)', textColor, - labelColor = 'rgba(255,255,255,0.4)', + labelColor, arcFrom = 'min', label, showValue = true, unit, format, + focusRing, className, style, ...core }) => { + const theme = useKnobTheme(); + const color = colorProp ?? theme.accent ?? '#e44cff'; const id = React.useId(); const glowId = `${id}-glow`; return ( - + = ({ unit={unit} format={format} fontSize={size * 0.17} - fontFamily={MONO_FONT} + fontFamily={theme.fontMono} textProps={{ filter: `url(#${glowId})` }} /> )} {label && ( - + {label} )} diff --git a/packages/dreamknob/src/skins/RubberKnob.tsx b/packages/dreamknob/src/skins/RubberKnob.tsx index ab13efd..4534d1e 100644 --- a/packages/dreamknob/src/skins/RubberKnob.tsx +++ b/packages/dreamknob/src/skins/RubberKnob.tsx @@ -4,7 +4,8 @@ import { Arc } from '../primitives/Arc'; import { GlowFilter } from '../primitives/GlowFilter'; import { Pointer } from '../primitives/Pointer'; import { KnobLabel, KnobValue } from '../primitives/Text'; -import { MONO_FONT, UI_FONT, type SkinProps } from './shared'; +import { useKnobTheme } from '../core/theme'; +import type { SkinProps } from './shared'; export interface RubberKnobProps extends SkinProps { /** Glow/accent color. */ @@ -18,19 +19,22 @@ export interface RubberKnobProps extends SkinProps { /** Soft-touch rubber knob with a glowing halo — modern synth hardware. */ export const RubberKnob: React.FC = ({ size = 90, - color = '#ff9640', - trackColor = 'rgba(255,255,255,0.08)', - textColor = 'rgba(255,255,255,0.92)', - labelColor = 'rgba(255,255,255,0.45)', + color: colorProp, + trackColor, + textColor, + labelColor, arcFrom = 'min', label, showValue = false, unit, format, + focusRing, className, style, ...core }) => { + const theme = useKnobTheme(); + const color = colorProp ?? theme.accent ?? '#ff9640'; const id = React.useId(); const bodyId = `${id}-body`; const glowId = `${id}-glow`; @@ -38,7 +42,7 @@ export const RubberKnob: React.FC = ({ const bodyR = size / 2 - size * 0.17; return ( - + @@ -52,7 +56,7 @@ export const RubberKnob: React.FC = ({ radius={size / 2 - size * 0.05} thickness={Math.max(3, size * 0.045)} color={color} - trackColor={trackColor} + trackColor={trackColor ?? theme.track} from={arcFrom} arcProps={{ filter: `url(#${glowId})` }} /> @@ -86,16 +90,21 @@ export const RubberKnob: React.FC = ({ /> {showValue && ( )} {label && ( - + {label} )} diff --git a/packages/dreamknob/src/skins/SteppedKnob.tsx b/packages/dreamknob/src/skins/SteppedKnob.tsx index 847c5a1..00e98aa 100644 --- a/packages/dreamknob/src/skins/SteppedKnob.tsx +++ b/packages/dreamknob/src/skins/SteppedKnob.tsx @@ -3,7 +3,8 @@ import { Knob } from '../components/Knob'; import { Pointer } from '../primitives/Pointer'; import { Ticks } from '../primitives/Ticks'; import { KnobLabel } from '../primitives/Text'; -import { MONO_FONT, UI_FONT, type SkinProps } from './shared'; +import { useKnobTheme } from '../core/theme'; +import type { SkinProps } from './shared'; export interface SteppedKnobProps extends SkinProps { /** Position names, e.g. ['LP','BP','HP']. Sets the number of detents. */ @@ -22,23 +23,35 @@ export const SteppedKnob: React.FC = ({ size = 90, positions, steps, - color = '#ffd23e', - tickColor = 'rgba(255,255,255,0.18)', + color: colorProp, + tickColor, faceColor = '#1c1d22', - textColor = 'rgba(255,255,255,0.92)', - labelColor = 'rgba(255,255,255,0.45)', + textColor, + labelColor, label, showValue = true, + focusRing, className, style, ...core }) => { + const theme = useKnobTheme(); + const color = colorProp ?? theme.accent ?? '#ffd23e'; const detents = positions ? positions.length : steps ?? 5; const min = core.min ?? 0; const max = core.max ?? (positions ? positions.length - 1 : 100); return ( - + {ctx => { const index = Math.round(ctx.normalized * (detents - 1)); return ( @@ -48,7 +61,7 @@ export const SteppedKnob: React.FC = ({ radius={size / 2 - 1} length={size * 0.07} width={Math.max(1.5, size * 0.022)} - color={tickColor} + color={tickColor ?? theme.ticks} getTickProps={i => (i === index ? { stroke: color } : undefined)} /> = ({ textAnchor="middle" dominantBaseline="central" fontSize={size * (positions ? 0.15 : 0.17)} - fill={textColor} - fontFamily={MONO_FONT} + fill={textColor ?? theme.text} + fontFamily={theme.fontMono} fontWeight={600} style={{ pointerEvents: 'none' }} > @@ -82,7 +95,12 @@ export const SteppedKnob: React.FC = ({ )} {label && ( - + {label} )} diff --git a/packages/dreamknob/src/skins/VintageKnob.tsx b/packages/dreamknob/src/skins/VintageKnob.tsx index e6b2e07..11b0346 100644 --- a/packages/dreamknob/src/skins/VintageKnob.tsx +++ b/packages/dreamknob/src/skins/VintageKnob.tsx @@ -5,7 +5,8 @@ import { TickLabels } from '../primitives/TickLabels'; import { KnobLabel } from '../primitives/Text'; import { polarToCartesian } from '../core/math'; import { useKnobContext } from '../core/context'; -import { UI_FONT, type SkinProps } from './shared'; +import { useKnobTheme } from '../core/theme'; +import type { SkinProps } from './shared'; /** VintageKnob has no numeric readout, so it doesn't accept readout props. */ export interface VintageKnobProps extends Omit { @@ -96,36 +97,46 @@ const ChickenHead: React.FC<{ bodyColor: string; indicatorColor: string }> = ({ export const VintageKnob: React.FC = ({ size = 96, bodyColor = '#efe6d0', - scaleColor = 'rgba(255,255,255,0.35)', + scaleColor, color, - labelColor = 'rgba(255,255,255,0.45)', + labelColor, scaleLabels, indicatorColor, label, + focusRing, className, style, ...core -}) => ( - - - {scaleLabels && } - - {label && ( - - {label} - - )} - -); +}) => { + const theme = useKnobTheme(); + const scale = scaleColor ?? theme.ticks; + return ( + + + {scaleLabels && } + + {label && ( + + {label} + + )} + + ); +}; diff --git a/packages/dreamknob/src/skins/shared.ts b/packages/dreamknob/src/skins/shared.ts index 48aaf8e..264c1a3 100644 --- a/packages/dreamknob/src/skins/shared.ts +++ b/packages/dreamknob/src/skins/shared.ts @@ -1,6 +1,8 @@ import type * as React from 'react'; import type { KnobCoreProps } from '../core/types'; +export { MONO_FONT, UI_FONT } from '../core/theme'; + /** Props common to every prebuilt knob skin. */ export interface SkinProps extends KnobCoreProps { /** Square canvas size in px. */ @@ -13,11 +15,15 @@ export interface SkinProps extends KnobCoreProps { unit?: string; /** Custom readout formatter (takes precedence over unit/decimals). */ format?: (value: number) => string; + /** Keyboard focus ring color, or false to disable. Defaults to the theme's. */ + focusRing?: string | false; + /** + * Type-in editing: double-click (knobs) or click the readout (faders) to + * enter an exact value. Enter/blur commits, Escape cancels, "1.2k" works. + */ + editable?: boolean; + /** Custom parser for typed input when `editable` is set. */ + parseValue?: (text: string) => number | null; className?: string; style?: React.CSSProperties; } - -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";