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:
parent
e9e52e43ed
commit
54189ad07a
15 changed files with 1069 additions and 53 deletions
16
README.md
16
README.md
|
|
@ -27,16 +27,18 @@ import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
|
||||||
<Fader min={-60} max={12} step={0.5} unit=" dB" defaultValue={-6} label="Out" />
|
<Fader min={-60} max={12} step={0.5} unit=" dB" defaultValue={-6} label="Out" />
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Feel**: grab-and-rotate rotary tracking (rc-knob style), or relative vertical /
|
- **Feel**: grab-and-rotate rotary tracking (rc-knob style) with pickup/relative modes,
|
||||||
horizontal drag (DAW plugin style), or absolute track dragging for faders. Scroll-wheel
|
or relative vertical / horizontal drag (DAW plugin style), or absolute track dragging
|
||||||
nudge, full keyboard support, Shift for fine control, double-click reset, touch via
|
for faders. Scroll-wheel nudge, full keyboard support, Shift+scroll for fine control,
|
||||||
pointer capture, proper `role="slider"` accessibility.
|
double-click reset, touch via pointer capture, proper `role="slider"` accessibility.
|
||||||
- **Numbers**: any range (negative, fractional, huge), decimal `step` with float-drift-safe
|
- **Numbers**: any range (negative, fractional, huge), decimal `step` with float-drift-safe
|
||||||
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
|
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
|
||||||
power / custom tapers.
|
power / custom tapers.
|
||||||
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
|
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
|
||||||
`SteppedKnob`, `Fader`, `LEDFader`, `SegmentDisplay` — every color themeable per instance.
|
`SteppedKnob`, `ImageKnob` (film-strips), `Fader`, `LEDFader`, `Meter`, `XYPad`,
|
||||||
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `Face`, `KnobValue`,
|
`PushButton`, `SegmentDisplay`, `AlphaDisplay` — every color themeable per instance.
|
||||||
`KnobLabel`) for custom designs, or go fully headless with `useKnob`.
|
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`, `Face`,
|
||||||
|
`KnobValue`, `KnobLabel`, `GlowFilter`) for custom designs, or go fully headless with
|
||||||
|
`useKnob`.
|
||||||
|
|
||||||
See `packages/dreamknob/README.md` and the docs app for the full API.
|
See `packages/dreamknob/README.md` and the docs app for the full API.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { AdvancedGuide } from './sections/AdvancedGuide';
|
||||||
import { ApiDocs } from './sections/ApiDocs';
|
import { ApiDocs } from './sections/ApiDocs';
|
||||||
import { Console } from './sections/Console';
|
import { Console } from './sections/Console';
|
||||||
import { Gallery } from './sections/Gallery';
|
import { Gallery } from './sections/Gallery';
|
||||||
|
import { GettingStarted } from './sections/GettingStarted';
|
||||||
import { Hero } from './sections/Hero';
|
import { Hero } from './sections/Hero';
|
||||||
import { Playground } from './sections/Playground';
|
import { Playground } from './sections/Playground';
|
||||||
|
import { Synth } from './sections/Synth';
|
||||||
|
|
||||||
const Logo: React.FC = () => (
|
const Logo: React.FC = () => (
|
||||||
<svg width="22" height="22" viewBox="0 0 100 100">
|
<svg width="22" height="22" viewBox="0 0 100 100">
|
||||||
|
|
@ -21,17 +24,22 @@ export const App: React.FC = () => (
|
||||||
dreamknob
|
dreamknob
|
||||||
</a>
|
</a>
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
|
<a href="#start">Start</a>
|
||||||
<a href="#gallery">Gallery</a>
|
<a href="#gallery">Gallery</a>
|
||||||
<a href="#console">Demo</a>
|
<a href="#synth">Synth</a>
|
||||||
<a href="#playground">Playground</a>
|
<a href="#playground">Playground</a>
|
||||||
|
<a href="#guide">Guide</a>
|
||||||
<a href="#docs">Docs</a>
|
<a href="#docs">Docs</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<Hero />
|
<Hero />
|
||||||
|
<GettingStarted />
|
||||||
<Gallery />
|
<Gallery />
|
||||||
<Console />
|
<Console />
|
||||||
|
<Synth />
|
||||||
<Playground />
|
<Playground />
|
||||||
|
<AdvancedGuide />
|
||||||
<ApiDocs />
|
<ApiDocs />
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
|
|
|
||||||
444
apps/docs/src/sections/AdvancedGuide.tsx
Normal file
444
apps/docs/src/sections/AdvancedGuide.tsx
Normal 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><Knob></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>
|
||||||
|
);
|
||||||
|
|
@ -36,8 +36,9 @@ export const ApiDocs: React.FC = () => (
|
||||||
<h2 className="section-title">API reference</h2>
|
<h2 className="section-title">API reference</h2>
|
||||||
<p className="section-sub">
|
<p className="section-sub">
|
||||||
Everything is built on one interaction core. The props below are shared by{' '}
|
Everything is built on one interaction core. The props below are shared by{' '}
|
||||||
<code>useKnob</code>, <code><Knob></code>, every prebuilt skin and{' '}
|
<code>useKnob</code>, <code><Knob></code>, every prebuilt knob skin and the
|
||||||
<code><Fader></code>.
|
faders. Rows marked ◇ are component-level (handled by the wrappers, not by{' '}
|
||||||
|
<code>useKnob</code>).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="api-h">Core value & interaction props</h3>
|
<h3 className="api-h">Core value & 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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="◇ 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="◇ 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." />
|
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|
@ -80,9 +82,9 @@ export const ApiDocs: React.FC = () => (
|
||||||
<code>↑/→</code> and <code>↓/←</code> step · <code>Shift</code>+arrows jump 10×
|
<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>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
|
<code>Esc</code> cancels a drag and restores the start value · scroll wheel nudges
|
||||||
(Shift = fine) · double-click resets (or opens the editor when{' '}
|
(Shift = fine on continuous values; snapped controls always move whole steps) ·
|
||||||
<code>editable</code>) · touch works via pointer capture · every control shows a
|
double-click resets (or opens the editor when <code>editable</code>) · touch works
|
||||||
keyboard-only focus ring.
|
via pointer capture · every control shows a keyboard-only focus ring.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 className="api-h">Prebuilt skins</h3>
|
<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." />
|
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
|
||||||
</Table>
|
</Table>
|
||||||
<p className="api-note">
|
<p className="api-note">
|
||||||
All skins also take <code>label</code>, <code>showValue</code>, <code>unit</code>,{' '}
|
Every knob skin and fader also takes <code>label</code>, <code>showValue</code>,{' '}
|
||||||
<code>format</code>, <code>className</code>, <code>style</code>, <code>name</code>{' '}
|
<code>unit</code>, <code>format</code>, <code>className</code>,{' '}
|
||||||
plus every core prop above, and forward a ref to their root element. Inside the
|
<code>style</code>, <code>name</code> plus every core prop above, and forwards a
|
||||||
SVG, parts carry <code>data-part</code> attributes (<code>arc</code>,{' '}
|
ref to its root element (<code>VintageKnob</code> has no readout, so it omits{' '}
|
||||||
<code>track</code>, <code>pointer</code>, <code>ticks</code>, <code>face</code>,{' '}
|
<code>showValue</code>/<code>unit</code>/<code>format</code>; the display
|
||||||
<code>value</code>, <code>label</code>) and the root exposes{' '}
|
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>{' '}
|
<code>data-dragging</code>/<code>data-disabled</code>/<code>data-focus-visible</code>{' '}
|
||||||
— style any state or part with plain CSS. <code><Arc></code> additionally
|
— style any state or part with plain CSS. <code><Arc></code> additionally
|
||||||
accepts <code>gradient</code> (position-anchored color stops).
|
accepts <code>gradient</code> (position-anchored color stops).
|
||||||
|
|
|
||||||
|
|
@ -165,9 +165,9 @@ export const Gallery: React.FC = () => (
|
||||||
<div className="section-kicker">Gallery</div>
|
<div className="section-kicker">Gallery</div>
|
||||||
<h2 className="section-title">Every style in the studio</h2>
|
<h2 className="section-title">Every style in the studio</h2>
|
||||||
<p className="section-sub">
|
<p className="section-sub">
|
||||||
Nine prebuilt skins — every color themeable per instance. All of them share the
|
A full rack of prebuilt controls — every color themeable per instance. All of
|
||||||
same interaction core: rotary or drag adjustment, scroll-wheel nudge, keyboard,
|
them share the same interaction core: rotary or drag adjustment, scroll-wheel
|
||||||
fine control and double-click reset.
|
nudge, keyboard, fine control and double-click reset.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
|
|
@ -341,7 +341,7 @@ export const Gallery: React.FC = () => (
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 />
|
<MeterDemo />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
155
apps/docs/src/sections/GettingStarted.tsx
Normal file
155
apps/docs/src/sections/GettingStarted.tsx
Normal 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 Hz–20 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 > 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><form></code> post.
|
||||||
|
</p>
|
||||||
|
</Step>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
@ -64,8 +64,8 @@ export const Hero: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rack-hint">
|
<div className="rack-hint">
|
||||||
drag to rotate · scroll to nudge · <kbd>⇧</kbd> for fine control · double-click to
|
drag to rotate · scroll to nudge · <kbd>⇧</kbd>+scroll for fine control ·
|
||||||
reset · arrow keys work too
|
double-click to reset · arrow keys work too
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,22 @@ import {
|
||||||
VintageKnob,
|
VintageKnob,
|
||||||
logTaper,
|
logTaper,
|
||||||
powTaper,
|
powTaper,
|
||||||
type InteractionMode,
|
|
||||||
} from 'dreamknob';
|
} from 'dreamknob';
|
||||||
import { CodeBlock } from '../components/CodeBlock';
|
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 =
|
type SkinId =
|
||||||
| 'flat'
|
| 'flat'
|
||||||
| 'metal'
|
| 'metal'
|
||||||
|
|
@ -44,7 +56,7 @@ interface Config {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
step: number;
|
step: number;
|
||||||
interaction: InteractionMode;
|
feel: Feel;
|
||||||
angleRange: number;
|
angleRange: number;
|
||||||
taper: 'linear' | 'log' | 'pow2';
|
taper: 'linear' | 'log' | 'pow2';
|
||||||
color: string;
|
color: string;
|
||||||
|
|
@ -61,7 +73,7 @@ const DEFAULTS: Config = {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
step: 0.5,
|
step: 0.5,
|
||||||
interaction: 'rotary',
|
feel: 'rotary',
|
||||||
angleRange: 270,
|
angleRange: 270,
|
||||||
taper: 'linear',
|
taper: 'linear',
|
||||||
color: '#4cc2ff',
|
color: '#4cc2ff',
|
||||||
|
|
@ -73,8 +85,10 @@ const DEFAULTS: Config = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCode = (c: Config, controlled: boolean): string => {
|
const buildCode = (c: Config, controlled: boolean): string => {
|
||||||
|
const linear = c.skin === 'fader' || c.skin === 'ledfader';
|
||||||
|
const noReadout = c.skin === 'vintage';
|
||||||
const props: string[] = [];
|
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}}`);
|
else props.push(`size={${c.size}}`);
|
||||||
if (controlled) props.push('value={value}', 'onChange={setValue}');
|
if (controlled) props.push('value={value}', 'onChange={setValue}');
|
||||||
else props.push(`defaultValue={${(c.min + c.max) / 2}}`);
|
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.step > 0) props.push(`step={${c.step}}`);
|
||||||
if (c.taper === 'log') props.push('taper={logTaper}');
|
if (c.taper === 'log') props.push('taper={logTaper}');
|
||||||
if (c.taper === 'pow2') props.push('taper={powTaper(2)}');
|
if (c.taper === 'pow2') props.push('taper={powTaper(2)}');
|
||||||
if (c.interaction !== 'rotary') props.push(`interaction="${c.interaction}"`);
|
// 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}}`);
|
if (c.angleRange !== 270) props.push(`angleRange={${c.angleRange}}`);
|
||||||
|
}
|
||||||
props.push(`color="${c.color}"`);
|
props.push(`color="${c.color}"`);
|
||||||
if (c.label) props.push(`label="${c.label}"`);
|
if (c.label) props.push(`label="${c.label}"`);
|
||||||
if (c.unit) props.push(`unit="${c.unit}"`);
|
if (c.unit && !noReadout) props.push(`unit="${c.unit}"`);
|
||||||
if (c.editable) props.push('editable');
|
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 === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
|
||||||
if (c.skin === 'stepped') props.push(`steps={5}`);
|
if (c.skin === 'stepped') props.push(`steps={5}`);
|
||||||
const name = SKINS[c.skin];
|
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 taper = cfg.taper === 'log' ? logTaper : cfg.taper === 'pow2' ? powTaper(2) : undefined;
|
||||||
const logInvalid = cfg.taper === 'log' && cfg.min <= 0;
|
const logInvalid = cfg.taper === 'log' && cfg.min <= 0;
|
||||||
|
const linear = cfg.skin === 'fader' || cfg.skin === 'ledfader';
|
||||||
|
|
||||||
const common = {
|
const common = {
|
||||||
value,
|
value,
|
||||||
|
|
@ -121,7 +142,7 @@ export const Playground: React.FC = () => {
|
||||||
max: cfg.max,
|
max: cfg.max,
|
||||||
step: cfg.step > 0 ? cfg.step : undefined,
|
step: cfg.step > 0 ? cfg.step : undefined,
|
||||||
taper: logInvalid ? undefined : taper,
|
taper: logInvalid ? undefined : taper,
|
||||||
interaction: cfg.interaction,
|
...feelToProps(cfg.feel),
|
||||||
angleRange: cfg.angleRange,
|
angleRange: cfg.angleRange,
|
||||||
color: cfg.color,
|
color: cfg.color,
|
||||||
label: cfg.label || undefined,
|
label: cfg.label || undefined,
|
||||||
|
|
@ -218,12 +239,15 @@ export const Playground: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="control">
|
<div className="control">
|
||||||
<label>Drag feel</label>
|
<label>Drag feel {linear && '· n/a for faders'}</label>
|
||||||
<select
|
<select
|
||||||
value={cfg.interaction}
|
value={cfg.feel}
|
||||||
onChange={e => set('interaction', e.target.value as InteractionMode)}
|
disabled={linear}
|
||||||
|
onChange={e => set('feel', e.target.value as Feel)}
|
||||||
>
|
>
|
||||||
<option value="rotary">rotary (grab & turn)</option>
|
<option value="rotary">rotary (grab & turn)</option>
|
||||||
|
<option value="pickup">rotary · pickup (no jump)</option>
|
||||||
|
<option value="relative">rotary · relative</option>
|
||||||
<option value="vertical">vertical drag</option>
|
<option value="vertical">vertical drag</option>
|
||||||
<option value="horizontal">horizontal drag</option>
|
<option value="horizontal">horizontal drag</option>
|
||||||
<option value="both">vertical + horizontal</option>
|
<option value="both">vertical + horizontal</option>
|
||||||
|
|
|
||||||
330
apps/docs/src/sections/Synth.tsx
Normal file
330
apps/docs/src/sections/Synth.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -41,7 +41,7 @@ Every component shares one core:
|
||||||
| Gesture | Behaviour |
|
| Gesture | Behaviour |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Drag | `rotary` (grab & turn, follows the pointer angle), `vertical`, `horizontal`, `both`, or absolute `track-*` (faders) |
|
| Drag | `rotary` (grab & turn, follows the pointer angle), `vertical`, `horizontal`, `both`, or absolute `track-*` (faders) |
|
||||||
| Scroll wheel | one `step` per notch, `Shift` = fine |
|
| Scroll wheel | one `step` per notch (else 1% of range); `Shift` = fine on continuous values — snapped controls always move whole steps |
|
||||||
| `↑ → / ↓ ←` | ± one step (`Shift` = 10×) |
|
| `↑ → / ↓ ←` | ± one step (`Shift` = 10×) |
|
||||||
| `PageUp/PageDown` | ± 10 % of range |
|
| `PageUp/PageDown` | ± 10 % of range |
|
||||||
| `Home/End` | min / max |
|
| `Home/End` | min / max |
|
||||||
|
|
@ -49,7 +49,8 @@ Every component shares one core:
|
||||||
| `Escape` | cancel an in-flight drag, restoring the start value |
|
| `Escape` | cancel an in-flight drag, restoring the start value |
|
||||||
| Touch / pen | pointer capture, `touch-action: none` |
|
| Touch / pen | pointer capture, `touch-action: none` |
|
||||||
|
|
||||||
All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
|
All knobs render `role="slider"` with `aria-valuemin/max/now` (and `aria-valuetext`
|
||||||
|
when you provide `getAriaValueText`).
|
||||||
|
|
||||||
## Value handling
|
## Value handling
|
||||||
|
|
||||||
|
|
@ -76,8 +77,10 @@ All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
|
||||||
- `animateChanges` — tween the pointer on programmatic changes (preset loads);
|
- `animateChanges` — tween the pointer on programmatic changes (preset loads);
|
||||||
gestures never animate and `prefers-reduced-motion` is honored.
|
gestures never animate and `prefers-reduced-motion` is honored.
|
||||||
- `valueBubble` — floating readout above the control while dragging.
|
- `valueBubble` — floating readout above the control while dragging.
|
||||||
- `name` — hidden form input for plain `<form>` posts; every component forwards a
|
- `name` — hidden form input for plain `<form>` posts on every knob, fader and
|
||||||
ref to its root element, and SVG parts carry `data-part` attributes for CSS.
|
`PushButton` (`XYPad` uses `nameX`/`nameY`). Interactive components forward a ref
|
||||||
|
to their root element, and primitive-drawn SVG parts carry `data-part` attributes
|
||||||
|
for CSS styling.
|
||||||
|
|
||||||
## Prebuilt skins
|
## Prebuilt skins
|
||||||
|
|
||||||
|
|
@ -114,8 +117,17 @@ import { DreamknobProvider } from 'dreamknob'
|
||||||
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
|
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
|
||||||
`text`, `label`, `ticks`, `focusRing`, `fontMono`, `fontUI`.
|
`text`, `label`, `ticks`, `focusRing`, `fontMono`, `fontUI`.
|
||||||
|
|
||||||
Every skin takes the core props plus `size`, `label`, `showValue`, `unit`, `format`,
|
Every knob skin and fader takes the core props plus `size`, `label`, `showValue`,
|
||||||
and per-part color props (`color`, `trackColor`, `faceColor`, `bodyColor`, …).
|
`unit`, `format`, and per-part color props (`color`, `trackColor`, `faceColor`,
|
||||||
|
`bodyColor`, …). `VintageKnob` has no readout, so it omits `showValue`/`unit`/`format`;
|
||||||
|
the display components (`Meter`, `SegmentDisplay`, `AlphaDisplay`) have their own
|
||||||
|
smaller prop sets.
|
||||||
|
|
||||||
|
Also exported for custom builds: primitives (`Arc`, `Pointer`, `Ticks`, `TickLabels`,
|
||||||
|
`Face`, `KnobValue`, `KnobLabel`, `GlowFilter`), theming (`useKnobTheme`, `darkTheme`,
|
||||||
|
`lightTheme`), editing (`ValueInput`, `defaultParseValue`), colors (`mixColors`,
|
||||||
|
`sampleGradient`), and the math kit (`clamp`, `roundTo`, `snapToStep`,
|
||||||
|
`decimalsFromStep`, `findClosest`, `applyDetents`, tapers, angle/arc helpers).
|
||||||
|
|
||||||
## Compose your own
|
## Compose your own
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,16 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||||
onChangeEndRef.current?.(s.startValue, { source: 'drag' });
|
onChangeEndRef.current?.(s.startValue, { source: 'drag' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A key gesture begins on the first adjusting keydown — bracket it with
|
||||||
|
// onChangeStart (before the value moves) like drags and wheel bursts.
|
||||||
|
const ADJUST_KEYS = [
|
||||||
|
'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft',
|
||||||
|
'PageUp', 'PageDown', 'Home', 'End',
|
||||||
|
];
|
||||||
|
if (ADJUST_KEYS.includes(e.key) && !keyAdjusted.current) {
|
||||||
|
keyAdjusted.current = true;
|
||||||
|
onChangeStartRef.current?.(valueRef.current, { source: 'keyboard' });
|
||||||
|
}
|
||||||
const span = max - min;
|
const span = max - min;
|
||||||
let handled = true;
|
let handled = true;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
/** Custom parser for typed input. */
|
/** Custom parser for typed input. */
|
||||||
parseValue?: (text: string) => number | null;
|
parseValue?: (text: string) => number | null;
|
||||||
|
/** Render a hidden form input carrying the current value. */
|
||||||
|
name?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +59,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
labelColor,
|
labelColor,
|
||||||
editable = false,
|
editable = false,
|
||||||
parseValue,
|
parseValue,
|
||||||
|
name,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
|
|
@ -256,6 +259,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
||||||
</div>
|
</div>
|
||||||
{label && (
|
{label && (
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ export interface ImageKnobProps extends KnobCoreProps {
|
||||||
/** Show a floating value bubble above the knob while dragging. */
|
/** Show a floating value bubble above the knob while dragging. */
|
||||||
valueBubble?: boolean;
|
valueBubble?: boolean;
|
||||||
bubbleFormat?: (value: number) => string;
|
bubbleFormat?: (value: number) => string;
|
||||||
|
/** Render a hidden form input carrying the current value. */
|
||||||
|
name?: string;
|
||||||
/** Extra styles for the image layer (e.g. filter, borderRadius). */
|
/** Extra styles for the image layer (e.g. filter, borderRadius). */
|
||||||
imageStyle?: React.CSSProperties;
|
imageStyle?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -53,6 +55,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
||||||
parseValue,
|
parseValue,
|
||||||
valueBubble = false,
|
valueBubble = false,
|
||||||
bubbleFormat,
|
bubbleFormat,
|
||||||
|
name,
|
||||||
imageStyle,
|
imageStyle,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
|
@ -158,6 +161,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
|
||||||
{(bubbleFormat ?? (() => text))(knob.value)}
|
{(bubbleFormat ?? (() => text))(knob.value)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
||||||
</div>
|
</div>
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
/** Custom parser for typed input. */
|
/** Custom parser for typed input. */
|
||||||
parseValue?: (text: string) => number | null;
|
parseValue?: (text: string) => number | null;
|
||||||
|
/** Render a hidden form input carrying the current value. */
|
||||||
|
name?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +70,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
labelColor,
|
labelColor,
|
||||||
editable = false,
|
editable = false,
|
||||||
parseValue,
|
parseValue,
|
||||||
|
name,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
...core
|
...core
|
||||||
|
|
@ -231,6 +234,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
|
||||||
/>
|
/>
|
||||||
{leds}
|
{leds}
|
||||||
</svg>
|
</svg>
|
||||||
|
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
|
||||||
</div>
|
</div>
|
||||||
{label && (
|
{label && (
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
const accent = color ?? theme.accent ?? '#4cc2ff';
|
const accent = color ?? theme.accent ?? '#4cc2ff';
|
||||||
const isControlled = pressed !== undefined;
|
const isControlled = pressed !== undefined;
|
||||||
const [internal, setInternal] = React.useState(defaultPressed);
|
const [internal, setInternal] = React.useState(defaultPressed);
|
||||||
|
const [focusVisible, setFocusVisible] = React.useState(false);
|
||||||
const on = isControlled ? (pressed as boolean) : internal;
|
const on = isControlled ? (pressed as boolean) : internal;
|
||||||
|
|
||||||
const set = (next: boolean) => {
|
const set = (next: boolean) => {
|
||||||
|
|
@ -85,7 +86,16 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
aria-label={aria['aria-label']}
|
aria-label={aria['aria-label']}
|
||||||
data-pressed={on ? '' : undefined}
|
data-pressed={on ? '' : undefined}
|
||||||
|
data-focus-visible={focusVisible ? '' : undefined}
|
||||||
className={className}
|
className={className}
|
||||||
|
onFocus={e => {
|
||||||
|
try {
|
||||||
|
setFocusVisible(e.currentTarget.matches(':focus-visible'));
|
||||||
|
} catch {
|
||||||
|
setFocusVisible(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setFocusVisible(false)}
|
||||||
{...momentaryProps}
|
{...momentaryProps}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
@ -103,9 +113,12 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
|
||||||
background: on
|
background: on
|
||||||
? 'linear-gradient(180deg, #17181d, #232429)'
|
? 'linear-gradient(180deg, #17181d, #232429)'
|
||||||
: 'linear-gradient(180deg, #35363d, #1d1e24)',
|
: 'linear-gradient(180deg, #35363d, #1d1e24)',
|
||||||
boxShadow: on
|
outline: 'none',
|
||||||
|
boxShadow: `${
|
||||||
|
on
|
||||||
? 'inset 0 2px 5px rgba(0,0,0,0.65)'
|
? 'inset 0 2px 5px rgba(0,0,0,0.65)'
|
||||||
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)',
|
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)'
|
||||||
|
}${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
|
||||||
color: on ? theme.text : theme.label,
|
color: on ? theme.text : theme.label,
|
||||||
fontFamily: theme.fontUI,
|
fontFamily: theme.fontUI,
|
||||||
fontSize: Math.max(10, size * 0.28),
|
fontSize: Math.max(10, size * 0.28),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue