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

@ -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" />