Docs accuracy pass, guides, and a playable Web Audio synth demo

Docs/playground accuracy (from a full docs-vs-source audit):
- Playground: drag-feel select covers pickup/relative rotary modes, is
  disabled for faders, and generated code no longer emits props the
  target component does not accept
- API/README claims corrected: Shift-fine wheel scope, wheelStep and
  decimals defaults, trackInset row, data-part and ref-forwarding
  scope, VintageKnob readout exception, Meter peakHold tag, stale
  component lists, aria-valuetext wording, also-exported list

API consistency fixes surfaced by the audit and the tutorial:
- Keyboard gestures now fire onChangeStart (bracket parity with drag/
  wheel - fixes gesture-scoped undo patterns)
- name (hidden form input) added to Fader, LEDFader, ImageKnob
- PushButton gets a themed keyboard-only focus ring + data-focus-visible

New docs content:
- Getting Started: 7-step guide with live examples and a gotchas list
- Advanced guide: 8-step channel-strip tutorial ending in a live,
  themed, gesture-undoable strip with meter (plus headless demo)
- Synth section: playable Web Audio synth - osc/filter/env/LFO/master
  all dreamknob controls, momentary-pad keyboard, analyser-driven Meter
This commit is contained in:
Dreamodus 2026-07-12 18:27:06 -07:00
parent e9e52e43ed
commit 54189ad07a
15 changed files with 1069 additions and 53 deletions

View file

@ -1,9 +1,12 @@
import React from 'react';
import { AdvancedGuide } from './sections/AdvancedGuide';
import { ApiDocs } from './sections/ApiDocs';
import { Console } from './sections/Console';
import { Gallery } from './sections/Gallery';
import { GettingStarted } from './sections/GettingStarted';
import { Hero } from './sections/Hero';
import { Playground } from './sections/Playground';
import { Synth } from './sections/Synth';
const Logo: React.FC = () => (
<svg width="22" height="22" viewBox="0 0 100 100">
@ -21,17 +24,22 @@ export const App: React.FC = () => (
dreamknob
</a>
<div className="nav-links">
<a href="#start">Start</a>
<a href="#gallery">Gallery</a>
<a href="#console">Demo</a>
<a href="#synth">Synth</a>
<a href="#playground">Playground</a>
<a href="#guide">Guide</a>
<a href="#docs">Docs</a>
</div>
</div>
</nav>
<Hero />
<GettingStarted />
<Gallery />
<Console />
<Synth />
<Playground />
<AdvancedGuide />
<ApiDocs />
<footer className="footer">
<div className="container">

View file

@ -0,0 +1,444 @@
import React from 'react';
import {
AlphaDisplay,
Arc,
DreamknobProvider,
Fader,
FlatKnob,
Knob,
KnobValue,
Meter,
Pointer,
PushButton,
useKnob,
} from 'dreamknob';
import { CodeBlock } from '../components/CodeBlock';
const Step: React.FC<{ n: number; title: string; children: React.ReactNode }> = ({
n,
title,
children,
}) => (
<div style={{ marginBottom: 36 }}>
<h3 className="api-h" style={{ marginTop: 0 }}>
<span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', marginRight: 10 }}>
{String(n).padStart(2, '0')}
</span>
{title}
</h3>
{children}
</div>
);
// ---------------------------------------------------------------------------
// The tutorial's finished product — a live channel strip with undo.
// ---------------------------------------------------------------------------
const ZONES = [
{ upTo: 0.72, color: '#3df2ad' },
{ upTo: 0.9, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
];
interface StripState {
gain: number;
pan: number;
drive: number;
level: number;
mute: boolean;
}
const INITIAL: StripState = { gain: 0, pan: 0, drive: 25, level: -10, mute: false };
const LiveStrip: React.FC = () => {
const [state, setState] = React.useState<StripState>(INITIAL);
const [undoStack, setUndoStack] = React.useState<StripState[]>([]);
const gestureStart = React.useRef<StripState | null>(null);
// Undo pattern: snapshot on gesture start, push it on gesture end.
const beginGesture = () => {
gestureStart.current = state;
};
const endGesture = () => {
if (gestureStart.current) {
const snap = gestureStart.current;
setUndoStack(s => [...s.slice(-19), snap]);
gestureStart.current = null;
}
};
const undo = () => {
setUndoStack(s => {
if (s.length === 0) return s;
setState(s[s.length - 1]);
return s.slice(0, -1);
});
};
const setPart = <K extends keyof StripState>(key: K, v: StripState[K]) =>
setState(p => ({ ...p, [key]: v }));
// A fake signal for the meter: level + drive, dancing a little.
const [signal, setSignal] = React.useState(-60);
React.useEffect(() => {
let t = 0;
const id = setInterval(() => {
t += 0.13;
const base = state.mute ? -60 : state.level + state.drive * 0.1;
setSignal(base + Math.sin(t) * 2 + Math.random() * 3);
}, 90);
return () => clearInterval(id);
}, [state.level, state.drive, state.mute]);
const db = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)}`;
return (
<DreamknobProvider theme={{ accent: '#3df2ad' }}>
<div
className="console"
style={{ gap: 26, alignItems: 'center', padding: '26px 30px' }}
>
<FlatKnob
size={72}
value={state.gain}
onChange={v => setPart('gain', v)}
onChangeStart={beginGesture}
onChangeEnd={endGesture}
min={-24}
max={24}
step={0.5}
origin={0}
detents={[0]}
arcFrom="center"
editable
format={db}
label="Gain"
aria-label="Strip gain"
/>
<FlatKnob
size={72}
value={state.pan}
onChange={v => setPart('pan', v)}
onChangeStart={beginGesture}
onChangeEnd={endGesture}
min={-50}
max={50}
step={1}
origin={0}
detents={[0]}
arcFrom="center"
color="#4cc2ff"
format={v => (v === 0 ? 'C' : v < 0 ? `L${-v}` : `R${v}`)}
label="Pan"
aria-label="Strip pan"
/>
<DriveKnob value={state.drive} onChange={v => setPart('drive', v)} onStart={beginGesture} onEnd={endGesture} />
<Fader
length={140}
value={state.level}
onChange={v => setPart('level', v)}
onChangeStart={beginGesture}
onChangeEnd={endGesture}
min={-60}
max={12}
step={0.5}
origin={0}
editable
format={v => `${db(v)} dB`}
label="Level"
aria-label="Strip level"
/>
<Meter
value={signal}
min={-60}
max={12}
length={140}
zones={ZONES}
peakColor="#fff"
label="Out"
aria-label="Strip output level"
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' }}>
<AlphaDisplay
value={state.mute ? 'MUTED' : `PAN ${state.pan === 0 ? 'C' : state.pan}`}
chars={7}
height={13}
color={state.mute ? '#ff4d6b' : '#3df2ad'}
/>
<PushButton
pressed={state.mute}
onChange={m => {
beginGesture();
setPart('mute', m);
endGesture();
}}
color="#ff4d6b"
aria-label="Mute"
>
Mute
</PushButton>
<PushButton
pressed={false}
onChange={undo}
led={false}
disabled={undoStack.length === 0}
aria-label="Undo"
>
Undo ({undoStack.length})
</PushButton>
</div>
</div>
</DreamknobProvider>
);
};
/** Tutorial step 4's custom composed knob, used in the live strip. */
const DriveKnob: React.FC<{
value: number;
onChange: (v: number) => void;
onStart: () => void;
onEnd: () => void;
}> = ({ value, onChange, onStart, onEnd }) => (
<Knob
size={72}
value={value}
onChange={onChange}
onChangeStart={onStart}
onChangeEnd={onEnd}
min={0}
max={100}
step={1}
aria-label="Strip drive"
>
<Arc
thickness={5}
trackColor="rgba(255,255,255,0.08)"
gradient={[
{ at: 0, color: '#3df2ad' },
{ at: 0.6, color: '#ffd23e' },
{ at: 1, color: '#ff4d6b' },
]}
/>
<Pointer type="line" radius={24} length={9} width={3} color="#e8eaf0" />
<KnobValue fontSize={13} color="#e8eaf0" />
<text
x={36}
y={64}
textAnchor="middle"
fontSize={7.5}
fill="rgba(255,255,255,0.45)"
letterSpacing="0.08em"
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
>
Drive
</text>
</Knob>
);
// ---------------------------------------------------------------------------
// A tiny headless example rendered live.
// ---------------------------------------------------------------------------
const HeadlessDemo: React.FC = () => {
const knob = useKnob({ min: 0, max: 11, step: 0.5, defaultValue: 11, 'aria-label': 'Volume (headless)' });
return (
<div
{...knob.bind}
style={{ ...knob.bind.style, width: 96, height: 96, position: 'relative', borderRadius: 12, background: '#16181f', border: '1px solid rgba(255,255,255,0.1)', display: 'grid', placeItems: 'center' }}
>
<div
style={{
fontSize: 30,
transform: `rotate(${knob.angle}deg)`,
transition: knob.isDragging ? undefined : 'transform 60ms linear',
lineHeight: 1,
}}
aria-hidden="true"
>
🎚
</div>
<span style={{ position: 'absolute', bottom: 6, fontFamily: 'var(--font-mono)', fontSize: 12, color: '#3df2ad' }}>
{knob.value}
</span>
</div>
);
};
export const AdvancedGuide: React.FC = () => (
<section className="block" id="guide">
<div className="container prose">
<div className="section-kicker">Advanced guide</div>
<h2 className="section-title">Tutorial: build a channel strip</h2>
<p className="section-sub">
Eight steps from a blank file to a themed, undoable channel strip with a custom
composed knob and a live meter. The finished result is running at the bottom.
</p>
<Step n={1} title="Theme the whole strip once">
<p className="api-note" style={{ marginBottom: 10 }}>
Wrap the strip in a provider so every control shares one accent, track, text
and font set. Individual props still override.
</p>
<CodeBlock
code={`import { DreamknobProvider } from 'dreamknob'
function ChannelStrip() {
return (
<DreamknobProvider theme={{ accent: '#3df2ad' }}>
{/* controls go here */}
</DreamknobProvider>
)
}`}
/>
</Step>
<Step n={2} title="Bipolar gain: origin + detents + type-in">
<p className="api-note" style={{ marginBottom: 10 }}>
A ±24 dB trim should fill its arc from <em>0 dB</em>, snap magnetically at 0
while dragging, and accept a typed value on double-click. Three props.
</p>
<CodeBlock
code={`<FlatKnob
value={gain} onChange={setGain}
min={-24} max={24} step={0.5}
origin={0} // arc fills from 0, not from -24
detents={[0]} // drags snap at 0
arcFrom="center"
editable // double-click → type "-6" or "3.5"
format={v => \`\${v > 0 ? '+' : ''}\${v.toFixed(1)}\`}
label="Gain" aria-label="Gain"
/>`}
/>
</Step>
<Step n={3} title="A pan pot that reads like hardware">
<p className="api-note" style={{ marginBottom: 10 }}>
<code>format</code> owns the readout entirely emit <code>L20 / C / R20</code>{' '}
while the value stays a clean number for your engine.
</p>
<CodeBlock
code={`<FlatKnob
value={pan} onChange={setPan}
min={-50} max={50} step={1}
origin={0} detents={[0]} arcFrom="center"
format={v => (v === 0 ? 'C' : v < 0 ? \`L\${-v}\` : \`R\${v}\`)}
label="Pan" aria-label="Pan"
/>`}
/>
</Step>
<Step n={4} title="Compose a custom knob from primitives">
<p className="api-note" style={{ marginBottom: 10 }}>
When no skin fits, <code>&lt;Knob&gt;</code> wires the interaction and the
primitives draw. This drive knob uses a gradient arc that heats up toward red
the colors stay anchored in place, only the sweep grows.
</p>
<CodeBlock
code={`import { Knob, Arc, Pointer, KnobValue } from 'dreamknob'
<Knob size={72} value={drive} onChange={setDrive} min={0} max={100} aria-label="Drive">
<Arc
thickness={5}
trackColor="rgba(255,255,255,0.08)"
gradient={[
{ at: 0, color: '#3df2ad' },
{ at: 0.6, color: '#ffd23e' },
{ at: 1, color: '#ff4d6b' },
]}
/>
<Pointer type="line" radius={24} length={9} width={3} color="#e8eaf0" />
<KnobValue fontSize={13} />
</Knob>`}
/>
</Step>
<Step n={5} title="Fader + meter, side by side">
<p className="api-note" style={{ marginBottom: 10 }}>
The fader is the control; the <code>Meter</code> is the display. Feed the meter
your engine's level (here we fake one) <code>peakHold</code> keeps the
loudest recent segment lit.
</p>
<CodeBlock
code={`<Fader
value={level} onChange={setLevel}
min={-60} max={12} step={0.5} origin={0}
editable format={v => \`\${v.toFixed(1)} dB\`}
label="Level" aria-label="Level"
/>
<Meter
value={engineLevel} // whatever your audio engine reports
min={-60} max={12}
zones={[
{ upTo: 0.72, color: '#3df2ad' },
{ upTo: 0.9, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
]}
peakColor="#fff"
aria-label="Output level"
/>`}
/>
</Step>
<Step n={6} title="Mute, status display, matching hardware">
<CodeBlock
code={`<PushButton pressed={mute} onChange={setMute} color="#ff4d6b" aria-label="Mute">
Mute
</PushButton>
<AlphaDisplay value={mute ? 'MUTED' : 'ACTIVE'} chars={7} height={13} />`}
/>
</Step>
<Step n={7} title="Undo that actually works">
<p className="api-note" style={{ marginBottom: 10 }}>
<code>onChangeStart</code>/<code>onChangeEnd</code> bracket every gesture
drag, wheel burst, key press, reset. Snapshot on start, push on end: one undo
entry per gesture instead of one per pixel. <code>meta.source</code> tells you
which input caused it if you need finer policy.
</p>
<CodeBlock
code={`const gestureStart = useRef(null)
const beginGesture = () => { gestureStart.current = state }
const endGesture = () => {
if (gestureStart.current) {
pushUndo(gestureStart.current)
gestureStart.current = null
}
}
<FlatKnob
value={state.gain}
onChange={v => setState(s => ({ ...s, gain: v }))}
onChangeStart={beginGesture}
onChangeEnd={endGesture}
...
/>`}
/>
</Step>
<Step n={8} title="Or skip our rendering entirely">
<p className="api-note" style={{ marginBottom: 10 }}>
<code>useKnob</code> gives you the state and the event bindings; you bring the
DOM. Everything rotary drag, wheel, keyboard, Escape, a11y still works.
This emoji is a fully functional knob:
</p>
<CodeBlock
code={`const knob = useKnob({ min: 0, max: 11, step: 0.5, defaultValue: 11, 'aria-label': 'Volume' })
<div {...knob.bind} style={{ ...knob.bind.style, width: 96, height: 96 }}>
<div style={{ transform: \`rotate(\${knob.angle}deg)\` }}>🎚️</div>
<span>{knob.value}</span>
</div>`}
/>
<div className="preview" style={{ minHeight: 140, marginTop: 12 }}>
<HeadlessDemo />
</div>
</Step>
<h3 className="api-h">The finished strip</h3>
<p className="api-note" style={{ marginBottom: 14 }}>
Everything above, assembled themed, undoable (try dragging then hitting Undo),
with a live meter. All state lives in one plain React object.
</p>
<LiveStrip />
</div>
</section>
);

View file

@ -36,8 +36,9 @@ export const ApiDocs: React.FC = () => (
<h2 className="section-title">API reference</h2>
<p className="section-sub">
Everything is built on one interaction core. The props below are shared by{' '}
<code>useKnob</code>, <code>&lt;Knob&gt;</code>, every prebuilt skin and{' '}
<code>&lt;Fader&gt;</code>.
<code>useKnob</code>, <code>&lt;Knob&gt;</code>, every prebuilt knob skin and the
faders. Rows marked are component-level (handled by the wrappers, not by{' '}
<code>useKnob</code>).
</p>
<h3 className="api-h">Core value &amp; interaction props</h3>
@ -46,7 +47,7 @@ export const ApiDocs: React.FC = () => (
<Row name="defaultValue" type="number" desc="Uncontrolled initial value; also the double-click reset target." />
<Row name="min / max" type="number" def="0 / 100" desc="Value range. Any numbers, including negative and fractional." />
<Row name="step" type="number" def="0 (continuous)" desc="Snap increment, e.g. 1, 0.5, 0.001. Decimal steps set display precision automatically." />
<Row name="decimals" type="number" def="from step" desc="Decimal places of emitted values (float-drift-safe rounding)." />
<Row name="decimals" type="number" def="from step (3 if continuous)" desc="Decimal places of emitted values (float-drift-safe rounding)." />
<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." />
@ -54,24 +55,25 @@ export const ApiDocs: React.FC = () => (
<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="wrap" type="boolean" def="false" desc="Endless encoder: values roll around min↔max. Pair with angleOffset={0} angleRange={360}." />
<Row name="animateChanges" type="boolean | {duration}" def="false" desc="Tween the pointer on programmatic changes (preset loads). Gestures never animate; respects prefers-reduced-motion." />
<Row name="valueBubble / bubbleFormat" type="boolean / fn" def="false" desc="Floating readout above the control while dragging." />
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
<Row name="rotaryMode" type="RotaryMode" def="'absolute'" desc="'absolute' tracks the pointer angle; 'relative' applies angle deltas (no grab jump); 'pickup' engages once the pointer sweeps past the value (MIDI pickup)." />
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
<Row name="trackInset" type="number" def="0" desc="Dead margin (px) at each end in track-* modes so pointer mapping matches a handle's travel (the faders set this for you)." />
<Row name="dragAcceleration" type="number" def="0" desc="Velocity gain for relative drags: fast flicks cover more range, slow drags stay precise. Shift bypasses it." />
<Row name="hideCursorOnDrag" type="boolean" def="false" desc="Hide the mouse cursor for the duration of a drag." />
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during drags and wheel." />
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during relative drags and wheel (continuous values — snapped controls always move whole steps)." />
<Row name="angleOffset / angleRange" type="number" def="225 / 270" desc="Where travel starts and how far it sweeps, in degrees clockwise from 12 o'clock." />
<Row name="enableWheel" type="boolean" def="true" desc="Scroll-wheel nudging. Trackpad deltas accumulate into notches so a flick can't rocket the value." />
<Row name="wheelStep" type="number" def="step" desc="Value change per wheel notch, independent of the snapping step." />
<Row name="wheelStep" type="number" def="step (else 1% of range)" desc="Value change per wheel notch, independent of the snapping step." />
<Row name="wheelRequiresFocus" type="boolean" def="false" desc="Only respond to the wheel while focused — hovering no longer captures page scroll." />
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
<Row name="onChange" type="(v, meta) => void" desc="Fires for every distinct snapped value. meta.source tells you what caused it: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'." />
<Row name="onChangeStart / onChangeEnd" type="(v, meta) => void" desc="Gesture boundaries (drag, wheel burst, key press, reset) — ideal for undo history or parameter automation." />
<Row name="name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work." />
<Row name="name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work (XYPad uses nameX/nameY)." />
<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="◇ 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="◇ valueBubble / bubbleFormat" type="boolean / fn" def="false" desc="Floating readout above knobs while dragging (knob skins + ImageKnob)." />
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
</Table>
@ -80,9 +82,9 @@ export const ApiDocs: React.FC = () => (
<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 ·
<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.
(Shift = fine on continuous values; snapped controls always move whole steps) ·
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>
@ -104,12 +106,16 @@ export const ApiDocs: React.FC = () => (
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
</Table>
<p className="api-note">
All skins also take <code>label</code>, <code>showValue</code>, <code>unit</code>,{' '}
<code>format</code>, <code>className</code>, <code>style</code>, <code>name</code>{' '}
plus every core prop above, and forward a ref to their root element. Inside the
SVG, parts carry <code>data-part</code> attributes (<code>arc</code>,{' '}
<code>track</code>, <code>pointer</code>, <code>ticks</code>, <code>face</code>,{' '}
<code>value</code>, <code>label</code>) and the root exposes{' '}
Every knob skin and fader also takes <code>label</code>, <code>showValue</code>,{' '}
<code>unit</code>, <code>format</code>, <code>className</code>,{' '}
<code>style</code>, <code>name</code> plus every core prop above, and forwards a
ref to its root element (<code>VintageKnob</code> has no readout, so it omits{' '}
<code>showValue</code>/<code>unit</code>/<code>format</code>; the display
components <code>Meter</code>/<code>SegmentDisplay</code>/<code>AlphaDisplay</code>{' '}
have their own smaller prop sets). Primitive-drawn parts carry{' '}
<code>data-part</code> attributes (<code>arc</code>, <code>track</code>,{' '}
<code>pointer</code>, <code>ticks</code>, <code>face</code>, <code>value</code>,{' '}
<code>label</code>) and every interactive root exposes{' '}
<code>data-dragging</code>/<code>data-disabled</code>/<code>data-focus-visible</code>{' '}
style any state or part with plain CSS. <code>&lt;Arc&gt;</code> additionally
accepts <code>gradient</code> (position-anchored color stops).

View file

@ -165,9 +165,9 @@ export const Gallery: React.FC = () => (
<div className="section-kicker">Gallery</div>
<h2 className="section-title">Every style in the studio</h2>
<p className="section-sub">
Nine prebuilt skins every color themeable per instance. All of them share the
same interaction core: rotary or drag adjustment, scroll-wheel nudge, keyboard,
fine control and double-click reset.
A full rack of prebuilt controls every color themeable per instance. All of
them share the same interaction core: rotary or drag adjustment, scroll-wheel
nudge, keyboard, fine control and double-click reset.
</p>
<div className="grid">
@ -341,7 +341,7 @@ export const Gallery: React.FC = () => (
</div>
</Card>
<Card title="Meter" desc="Read-only LED metering with peak hold — the display sibling of LEDFader." tag="<Meter peakHold />">
<Card title="Meter" desc="Read-only LED metering with peak hold — the display sibling of LEDFader." tag="<Meter peakHold={1200} />">
<MeterDemo />
</Card>

View file

@ -0,0 +1,155 @@
import React from 'react';
import { FlatKnob, logTaper } from 'dreamknob';
import { CodeBlock } from '../components/CodeBlock';
const Step: React.FC<{ n: number; title: string; children: React.ReactNode }> = ({
n,
title,
children,
}) => (
<div style={{ marginBottom: 34 }}>
<h3 className="api-h" style={{ marginTop: 0 }}>
<span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', marginRight: 10 }}>
{String(n).padStart(2, '0')}
</span>
{title}
</h3>
{children}
</div>
);
export const GettingStarted: React.FC = () => (
<section className="block" id="start">
<div className="container prose">
<div className="section-kicker">Getting started</div>
<h2 className="section-title">Your first knob in five minutes</h2>
<p className="section-sub">
Everything below is copy-pasteable. The only rule to remember: every control needs
an <code>aria-label</code> (or a visible label wired via <code>aria-labelledby</code>).
</p>
<Step n={1} title="Install">
<CodeBlock code={`npm install dreamknob\n# peer deps: react >= 18, react-dom >= 18`} />
<p className="api-note" style={{ marginTop: 10 }}>
Zero runtime dependencies, ESM + CJS, full TypeScript types. No CSS file to
import everything is SVG and inline styles.
</p>
</Step>
<Step n={2} title="Drop in a knob (uncontrolled)">
<p className="api-note" style={{ marginBottom: 10 }}>
Pass <code>defaultValue</code> and the knob manages its own state.{' '}
<code>defaultValue</code> is also the double-click reset target.
</p>
<CodeBlock
code={`import { FlatKnob } from 'dreamknob'
<FlatKnob
size={90}
min={0} max={100} step={1}
defaultValue={50}
label="Level" unit="%"
aria-label="Level"
/>`}
/>
<div className="preview" style={{ minHeight: 150, marginTop: 12 }}>
<FlatKnob size={90} min={0} max={100} step={1} defaultValue={50} label="Level" unit="%" aria-label="Level" />
</div>
</Step>
<Step n={3} title="Read the value (controlled)">
<p className="api-note" style={{ marginBottom: 10 }}>
For app state, pass <code>value</code> + <code>onChange</code>. The second
argument tells you what caused the change useful later for undo/automation.
</p>
<CodeBlock
code={`const [gain, setGain] = useState(-6)
<FlatKnob
value={gain}
onChange={(v, meta) => setGain(v)} // meta.source: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'
min={-60} max={12} step={0.5}
unit=" dB"
aria-label="Gain"
/>`}
/>
</Step>
<Step n={4} title="Pick the feel">
<p className="api-note" style={{ marginBottom: 10 }}>
The default is grab-and-rotate (the pointer angle drives the value). Prefer the
up/down plugin feel, or MIDI-style pickup? One prop each. Users always get
scroll-wheel, arrow keys, Shift-fine, Escape-cancel and double-click reset for
free.
</p>
<CodeBlock
code={`<FlatKnob interaction="vertical" ... /> // drag up/down like a plugin
<FlatKnob rotaryMode="pickup" ... /> // never jumps when grabbed off-position
<FlatKnob rotaryMode="relative" ... /> // angle deltas, no jump either`}
/>
</Step>
<Step n={5} title="Get the numbers right">
<p className="api-note" style={{ marginBottom: 10 }}>
<code>step</code> snaps and sets display precision (float-safe no{' '}
<code>0.30000000000000004</code>). For audio ranges, use a taper:{' '}
<code>logTaper</code> gives equal ratios per turn, so 20 Hz20 kHz feels right.
<code>format</code> controls display without touching the emitted value.
</p>
<CodeBlock
code={`import { FlatKnob, logTaper } from 'dreamknob'
<FlatKnob
min={20} max={20000}
taper={logTaper} // min must be > 0 for log
defaultValue={440}
decimals={0}
format={v => v >= 1000 ? \`\${(v / 1000).toFixed(1)}k\` : \`\${Math.round(v)}\`}
label="Freq"
aria-label="Frequency"
/>`}
/>
<div className="preview" style={{ minHeight: 150, marginTop: 12 }}>
<FlatKnob
size={90}
min={20}
max={20000}
taper={logTaper}
defaultValue={440}
decimals={0}
color="#3df2ad"
format={v => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
label="Freq"
aria-label="Frequency example"
/>
</div>
</Step>
<Step n={6} title="Theme once, restyle everything">
<CodeBlock
code={`import { DreamknobProvider } from 'dreamknob'
<DreamknobProvider base="dark" theme={{ accent: '#ff4d6b' }}>
{/* every knob/fader/meter below inherits the tokens */}
</DreamknobProvider>`}
/>
<p className="api-note" style={{ marginTop: 10 }}>
Use <code>base="light"</code> on light backgrounds. Per-instance color props
always win over the theme.
</p>
</Step>
<Step n={7} title="Gotchas checklist">
<p className="api-note">
Always pass <code>aria-label</code>. <code>logTaper</code> needs{' '}
<code>min &gt; 0</code> (it falls back to linear otherwise). Controlled mode
means <em>you</em> own the value if you don't call <code>setState</code> in{' '}
<code>onChange</code>, the knob won't move. Hovering captures the scroll
wheel by default; pass <code>wheelRequiresFocus</code> if your UI scrolls.
The knob is a <code>div</code>, not an input use <code>name="…"</code> if you
need it in a plain <code>&lt;form&gt;</code> post.
</p>
</Step>
</div>
</section>
);

View file

@ -64,8 +64,8 @@ export const Hero: React.FC = () => {
/>
</div>
<div className="rack-hint">
drag to rotate · scroll to nudge · <kbd></kbd> for fine control · double-click to
reset · arrow keys work too
drag to rotate · scroll to nudge · <kbd></kbd>+scroll for fine control ·
double-click to reset · arrow keys work too
</div>
</div>
</div>

View file

@ -11,10 +11,22 @@ import {
VintageKnob,
logTaper,
powTaper,
type InteractionMode,
} from 'dreamknob';
import { CodeBlock } from '../components/CodeBlock';
/** Playground "drag feel" — maps to interaction + rotaryMode core props. */
type Feel = 'rotary' | 'pickup' | 'relative' | 'vertical' | 'horizontal' | 'both';
const feelToProps = (feel: Feel) => ({
interaction: (feel === 'vertical' || feel === 'horizontal' || feel === 'both'
? feel
: 'rotary') as 'rotary' | 'vertical' | 'horizontal' | 'both',
rotaryMode: (feel === 'pickup' || feel === 'relative' ? feel : 'absolute') as
| 'absolute'
| 'pickup'
| 'relative',
});
type SkinId =
| 'flat'
| 'metal'
@ -44,7 +56,7 @@ interface Config {
min: number;
max: number;
step: number;
interaction: InteractionMode;
feel: Feel;
angleRange: number;
taper: 'linear' | 'log' | 'pow2';
color: string;
@ -61,7 +73,7 @@ const DEFAULTS: Config = {
min: 0,
max: 100,
step: 0.5,
interaction: 'rotary',
feel: 'rotary',
angleRange: 270,
taper: 'linear',
color: '#4cc2ff',
@ -73,8 +85,10 @@ const DEFAULTS: Config = {
};
const buildCode = (c: Config, controlled: boolean): string => {
const linear = c.skin === 'fader' || c.skin === 'ledfader';
const noReadout = c.skin === 'vintage';
const props: string[] = [];
if (c.skin === 'fader' || c.skin === 'ledfader') props.push(`length={${c.size * 1.4}}`);
if (linear) props.push(`length={${c.size * 1.4}}`);
else props.push(`size={${c.size}}`);
if (controlled) props.push('value={value}', 'onChange={setValue}');
else props.push(`defaultValue={${(c.min + c.max) / 2}}`);
@ -83,12 +97,18 @@ const buildCode = (c: Config, controlled: boolean): string => {
if (c.step > 0) props.push(`step={${c.step}}`);
if (c.taper === 'log') props.push('taper={logTaper}');
if (c.taper === 'pow2') props.push('taper={powTaper(2)}');
if (c.interaction !== 'rotary') props.push(`interaction="${c.interaction}"`);
if (c.angleRange !== 270) props.push(`angleRange={${c.angleRange}}`);
// Linear controls own their interaction and geometry — those props don't apply.
if (!linear) {
const feel = feelToProps(c.feel);
if (feel.interaction !== 'rotary') props.push(`interaction="${feel.interaction}"`);
if (feel.rotaryMode !== 'absolute') props.push(`rotaryMode="${feel.rotaryMode}"`);
if (c.angleRange !== 270) props.push(`angleRange={${c.angleRange}}`);
}
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.unit && !noReadout) props.push(`unit="${c.unit}"`);
if (!c.showValue && !noReadout) props.push('showValue={false}');
if (c.editable && !noReadout) 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];
@ -112,6 +132,7 @@ export const Playground: React.FC = () => {
const taper = cfg.taper === 'log' ? logTaper : cfg.taper === 'pow2' ? powTaper(2) : undefined;
const logInvalid = cfg.taper === 'log' && cfg.min <= 0;
const linear = cfg.skin === 'fader' || cfg.skin === 'ledfader';
const common = {
value,
@ -121,7 +142,7 @@ export const Playground: React.FC = () => {
max: cfg.max,
step: cfg.step > 0 ? cfg.step : undefined,
taper: logInvalid ? undefined : taper,
interaction: cfg.interaction,
...feelToProps(cfg.feel),
angleRange: cfg.angleRange,
color: cfg.color,
label: cfg.label || undefined,
@ -218,12 +239,15 @@ export const Playground: React.FC = () => {
</div>
</div>
<div className="control">
<label>Drag feel</label>
<label>Drag feel {linear && '· n/a for faders'}</label>
<select
value={cfg.interaction}
onChange={e => set('interaction', e.target.value as InteractionMode)}
value={cfg.feel}
disabled={linear}
onChange={e => set('feel', e.target.value as Feel)}
>
<option value="rotary">rotary (grab &amp; turn)</option>
<option value="pickup">rotary · pickup (no jump)</option>
<option value="relative">rotary · relative</option>
<option value="vertical">vertical drag</option>
<option value="horizontal">horizontal drag</option>
<option value="both">vertical + horizontal</option>

View file

@ -0,0 +1,330 @@
import React from 'react';
import {
AlphaDisplay,
Fader,
FlatKnob,
LEDKnob,
Meter,
NeonKnob,
PushButton,
RubberKnob,
SteppedKnob,
logTaper,
powTaper,
} from 'dreamknob';
const NOTES = [
{ name: 'C3', freq: 130.81 },
{ name: 'D3', freq: 146.83 },
{ name: 'E3', freq: 164.81 },
{ name: 'G3', freq: 196.0 },
{ name: 'A3', freq: 220.0 },
{ name: 'C4', freq: 261.63 },
{ name: 'D4', freq: 293.66 },
{ name: 'E4', freq: 329.63 },
];
const WAVES: OscillatorType[] = ['sawtooth', 'square', 'triangle'];
interface Params {
wave: number;
cutoff: number;
res: number;
attack: number; // ms
release: number; // ms
lfoRate: number;
lfoDepth: number;
master: number; // dB
}
interface Engine {
ctx: AudioContext;
osc: OscillatorNode;
env: GainNode;
filter: BiquadFilterNode;
lfo: OscillatorNode;
lfoGain: GainNode;
master: GainNode;
analyser: AnalyserNode;
}
export const Synth: React.FC = () => {
const [powered, setPowered] = React.useState(false);
const [params, setParams] = React.useState<Params>({
wave: 0,
cutoff: 1200,
res: 6,
attack: 12,
release: 260,
lfoRate: 4,
lfoDepth: 15,
master: -12,
});
const [note, setNote] = React.useState<string | null>(null);
const [meterDb, setMeterDb] = React.useState(-60);
const engine = React.useRef<Engine | null>(null);
const paramsRef = React.useRef(params);
paramsRef.current = params;
const set = <K extends keyof Params>(key: K, v: Params[K]) =>
setParams(p => ({ ...p, [key]: v }));
// Build / tear down the audio graph with power.
React.useEffect(() => {
if (!powered) return;
const ctx = new AudioContext();
const p = paramsRef.current;
const osc = ctx.createOscillator();
osc.type = WAVES[p.wave];
osc.frequency.value = 220;
const env = ctx.createGain();
env.gain.value = 0;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = p.cutoff;
filter.Q.value = p.res;
const lfo = ctx.createOscillator();
lfo.frequency.value = p.lfoRate;
const lfoGain = ctx.createGain();
lfoGain.gain.value = p.lfoDepth * 12; // depth% -> Hz swing on the cutoff
const master = ctx.createGain();
master.gain.value = Math.pow(10, p.master / 20);
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
osc.connect(env);
env.connect(filter);
filter.connect(master);
master.connect(analyser);
analyser.connect(ctx.destination);
lfo.connect(lfoGain);
lfoGain.connect(filter.frequency);
osc.start();
lfo.start();
engine.current = { ctx, osc, env, filter, lfo, lfoGain, master, analyser };
// Meter: RMS of the output, throttled to ~25 fps.
const buf = new Float32Array(analyser.fftSize);
let raf = 0;
let last = 0;
const tick = (t: number) => {
raf = requestAnimationFrame(tick);
if (t - last < 40) return;
last = t;
analyser.getFloatTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
const rms = Math.sqrt(sum / buf.length);
setMeterDb(rms > 0 ? Math.max(-60, 20 * Math.log10(rms)) : -60);
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
engine.current = null;
void ctx.close();
setMeterDb(-60);
setNote(null);
};
}, [powered]);
// Live parameter updates.
React.useEffect(() => {
const e = engine.current;
if (!e) return;
const t = e.ctx.currentTime;
e.osc.type = WAVES[params.wave];
e.filter.frequency.setTargetAtTime(params.cutoff, t, 0.015);
e.filter.Q.setTargetAtTime(params.res, t, 0.015);
e.lfo.frequency.setTargetAtTime(params.lfoRate, t, 0.015);
e.lfoGain.gain.setTargetAtTime(params.lfoDepth * 12, t, 0.015);
e.master.gain.setTargetAtTime(Math.pow(10, params.master / 20), t, 0.015);
}, [params]);
const noteOn = (freq: number, name: string) => {
const e = engine.current;
if (!e) return;
const t = e.ctx.currentTime;
e.osc.frequency.setTargetAtTime(freq, t, 0.002);
e.env.gain.cancelScheduledValues(t);
e.env.gain.setValueAtTime(e.env.gain.value, t);
e.env.gain.linearRampToValueAtTime(0.5, t + paramsRef.current.attack / 1000);
setNote(name);
};
const noteOff = () => {
const e = engine.current;
if (!e) return;
const t = e.ctx.currentTime;
e.env.gain.cancelScheduledValues(t);
e.env.gain.setValueAtTime(e.env.gain.value, t);
e.env.gain.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000);
setNote(null);
};
const hz = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`);
return (
<section className="block" id="synth">
<div className="container">
<div className="section-kicker">Play it</div>
<h2 className="section-title">A real synth, all dreamknob controls</h2>
<p className="section-sub">
Every control drives an actual Web Audio graph power it on, hold the pads, and
sweep the filter. The meter reads the analyser; the display shows the note.
</p>
<div className="console" style={{ flexDirection: 'column', gap: 22 }}>
<div style={{ display: 'flex', gap: 26, alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap' }}>
<PushButton
pressed={powered}
onChange={setPowered}
color="#ff4d6b"
size={40}
aria-label="Power"
>
Power
</PushButton>
<AlphaDisplay
value={!powered ? 'OFF' : (note ?? 'READY')}
chars={6}
align="right"
height={18}
color={powered ? '#3df2ad' : '#3a3f46'}
/>
<SteppedKnob
size={76}
positions={['SAW', 'SQR', 'TRI']}
value={params.wave}
onChange={v => set('wave', v)}
color="#ffd23e"
label="Osc"
aria-label="Oscillator wave"
/>
<RubberKnob
size={76}
min={60}
max={12000}
taper={logTaper}
decimals={0}
value={params.cutoff}
onChange={v => set('cutoff', v)}
showValue
format={hz}
color="#ff9640"
label="Cutoff"
aria-label="Filter cutoff"
/>
<FlatKnob
size={76}
min={0}
max={24}
step={0.5}
value={params.res}
onChange={v => set('res', v)}
color="#ff9640"
label="Res"
aria-label="Filter resonance"
/>
<FlatKnob
size={76}
min={1}
max={1000}
taper={powTaper(2.5)}
decimals={0}
value={params.attack}
onChange={v => set('attack', v)}
unit="ms"
color="#4cc2ff"
label="Attack"
aria-label="Envelope attack"
/>
<FlatKnob
size={76}
min={10}
max={3000}
taper={powTaper(2.5)}
decimals={0}
value={params.release}
onChange={v => set('release', v)}
unit="ms"
color="#4cc2ff"
label="Release"
aria-label="Envelope release"
/>
<LEDKnob
size={76}
min={0.1}
max={20}
taper={logTaper}
decimals={1}
value={params.lfoRate}
onChange={v => set('lfoRate', v)}
digits={4}
displayDecimals={1}
color="#3df2ad"
label="LFO Hz"
aria-label="LFO rate"
/>
<NeonKnob
size={76}
min={0}
max={100}
step={1}
value={params.lfoDepth}
onChange={v => set('lfoDepth', v)}
color="#e44cff"
label="Depth"
aria-label="LFO depth"
/>
<Fader
length={120}
breadth={40}
min={-60}
max={0}
step={0.5}
value={params.master}
onChange={v => set('master', v)}
origin={-60}
editable
unit=" dB"
label="Master"
aria-label="Master volume"
/>
<Meter
value={meterDb}
min={-60}
max={0}
length={120}
zones={[
{ upTo: 0.7, color: '#3df2ad' },
{ upTo: 0.9, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
]}
peakColor="#fff"
label="Out"
aria-label="Synth output level"
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
{NOTES.map(n => (
<PushButton
key={n.name}
mode="momentary"
onChange={on => (on ? noteOn(n.freq, n.name) : noteOff())}
color="#3df2ad"
size={44}
disabled={!powered}
aria-label={`Play ${n.name}`}
>
{n.name}
</PushButton>
))}
</div>
<div className="rack-hint" style={{ marginTop: 0 }}>
power on · hold a pad · sweep the cutoff while a note rings
</div>
</div>
</div>
</section>
);
};