Tier 1: origin anchor, detents, theming, type-in editing, ImageKnob

- 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
This commit is contained in:
Dreamodus 2026-07-12 15:05:46 -07:00
parent d8992408fa
commit 804a85cca6
25 changed files with 999 additions and 162 deletions

View file

@ -50,6 +50,8 @@ export const ApiDocs: React.FC = () => (
<Row name="values" type="number[]" desc="Restrict to a discrete list, e.g. [0.25, 0.5, 1, 2, 4]. Overrides step." />
<Row name="steps" type="number" desc="Number of evenly spaced detents across the travel (selector knobs)." />
<Row name="taper" type="Taper" def="linearTaper" desc="Travel curve: linearTaper, logTaper (frequencies), powTaper(n) (gain), or your own." />
<Row name="origin" type="number" desc="Bipolar anchor: arcs and fader fills draw from this value instead of the minimum (origin={0} for pan/gain)." />
<Row name="detents / detentSize" type="number[] / number" def="— / 0.025" desc="Magnetic snap points: drags stick to these values within the capture radius. Wheel/keyboard unaffected." />
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during drags and wheel." />
@ -59,6 +61,8 @@ export const ApiDocs: React.FC = () => (
<Row name="onChange" type="(v: number) => void" desc="Fires for every distinct snapped value." />
<Row name="onChangeStart / onChangeEnd" type="(v: number) => void" desc="Gesture boundaries — ideal for undo history or parameter automation." />
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
<Row name="editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." />
<Row name="focusRing" type="string | false" def="theme" desc="Keyboard focus ring color. Pointer grabs never show it." />
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
</Table>
@ -66,8 +70,10 @@ export const ApiDocs: React.FC = () => (
<p className="api-note">
<code>/</code> and <code>/</code> step · <code>Shift</code>+arrows jump 10×
· <code>PageUp/PageDown</code> move 10% · <code>Home/End</code> jump to min/max ·
scroll wheel nudges (Shift = fine) · double-click resets · touch works via pointer
capture.
<code>Esc</code> cancels a drag and restores the start value · scroll wheel nudges
(Shift = fine) · double-click resets (or opens the editor when{' '}
<code>editable</code>) · touch works via pointer capture · every control shows a
keyboard-only focus ring.
</p>
<h3 className="api-h">Prebuilt skins</h3>
@ -81,6 +87,7 @@ export const ApiDocs: React.FC = () => (
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." />
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
<Row name="<ImageKnob />" type="sprite" desc="Film-strip knob (KnobMan-style PNG strips): src, frames, stripOrientation. Photoreal skins with zero drawing code." />
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
</Table>
<p className="api-note">
@ -89,6 +96,28 @@ export const ApiDocs: React.FC = () => (
above.
</p>
<h3 className="api-h">Theming</h3>
<p className="api-note">
Every skin resolves its color and font defaults from the active theme; per-instance
props always win. Use <code>base="light"</code> for light backgrounds.
</p>
<CodeBlock
code={`import { DreamknobProvider } from 'dreamknob'
<DreamknobProvider
base="dark" // or "light"
theme={{
accent: '#ff4d6b', // one accent for the whole console
track: 'rgba(255,255,255,0.1)',
text: '#fff',
label: 'rgba(255,255,255,0.5)',
fontMono: '"IBM Plex Mono", monospace',
}}
>
<FlatKnob ... /> <Fader ... /> <LEDKnob ... />
</DreamknobProvider>`}
/>
<h3 className="api-h">Compose your own knob</h3>
<p className="api-note">
<code>&lt;Knob&gt;</code> wires up the interaction and provides a render context;

View file

@ -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<string | null>(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 (
<ImageKnob
src={src}
frames={31}
size={92}
defaultValue={40}
step={1}
showValue
label="Sprite"
aria-label="Film-strip demo"
/>
);
};
const Card: React.FC<{
title: string;
desc: string;
@ -131,6 +187,88 @@ export const Gallery: React.FC = () => (
/>
</Card>
<Card title="Film-strip" desc="Photoreal sprite knobs — any KnobMan-style PNG strip works. This one is drawn at runtime." tag="<ImageKnob />">
<ImageKnobDemo />
</Card>
<Card title="Bipolar origin + detent" desc="Fill anchors at value 0 (not mid-travel) and the drag snaps magnetically at 0." tag="origin={0} detents={[0]}">
<FlatKnob
size={92}
min={-60}
max={12}
step={0.5}
defaultValue={0}
origin={0}
detents={[0]}
arcFrom="center"
color="#3df2ad"
format={v => `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
label="Gain"
aria-label="Bipolar gain demo"
/>
<Fader
length={130}
min={-60}
max={12}
step={0.5}
defaultValue={0}
origin={0}
detents={[0]}
color="#3df2ad"
format={v => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
label="Trim"
aria-label="Bipolar fader demo"
/>
</Card>
<Card title="Type-in editing" desc="Double-click the knob (or click a fader readout) and type the exact value — '1.2k' works too." tag="editable">
<FlatKnob
size={92}
min={20}
max={20000}
taper={logTaper}
decimals={0}
defaultValue={440}
editable
color="#4cc2ff"
format={v => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
label="Freq"
aria-label="Editable demo"
/>
<Fader
length={130}
min={-60}
max={12}
step={0.5}
defaultValue={-6}
editable
unit=" dB"
label="Out"
aria-label="Editable fader demo"
/>
</Card>
<Card title="Theming" desc="One provider restyles every control — accent, tracks, text, fonts. Light backgrounds too." tag="<DreamknobProvider />">
<DreamknobProvider theme={{ accent: '#ff4d6b' }}>
<FlatKnob size={70} defaultValue={62} label="A" aria-label="Themed A" />
<RubberKnob size={70} defaultValue={35} label="B" aria-label="Themed B" />
</DreamknobProvider>
<div
style={{
background: '#eceef2',
borderRadius: 10,
padding: '12px 16px',
display: 'flex',
gap: 14,
alignItems: 'center',
}}
>
<DreamknobProvider base="light" theme={{ accent: '#0868c8' }}>
<FlatKnob size={70} defaultValue={62} label="Light" aria-label="Light themed" />
</DreamknobProvider>
</div>
</Card>
<Card title="Compose your own" desc="Primitives + headless core = any design you want." tag="<Knob> + primitives">
<Knob size={96} defaultValue={42} aria-label="Composed demo">
<Ticks count={28} radius={47} length={5} width={1.5} color="rgba(255,255,255,0.12)" activeColor="#ffd23e" />

View file

@ -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
</label>
</div>
<div className="control">
<label className="check">
<input
type="checkbox"
checked={cfg.editable}
onChange={e => set('editable', e.target.checked)}
/>
type-in editing (dbl-click / click readout)
</label>
</div>
</div>
<div className="stage">