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

@ -41,7 +41,7 @@ Every component shares one core:
| Gesture | Behaviour |
| --- | --- |
| 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×) |
| `PageUp/PageDown` | ± 10 % of range |
| `Home/End` | min / max |
@ -49,7 +49,8 @@ Every component shares one core:
| `Escape` | cancel an in-flight drag, restoring the start value |
| Touch / pen | pointer capture, `touch-action: none` |
All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
All knobs render `role="slider"` with `aria-valuemin/max/now` (and `aria-valuetext`
when you provide `getAriaValueText`).
## 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);
gestures never animate and `prefers-reduced-motion` is honored.
- `valueBubble` — floating readout above the control while dragging.
- `name` — hidden form input for plain `<form>` posts; every component forwards a
ref to its root element, and SVG parts carry `data-part` attributes for CSS.
- `name` — hidden form input for plain `<form>` posts on every knob, fader and
`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
@ -114,8 +117,17 @@ import { DreamknobProvider } from 'dreamknob'
`base="light"` swaps in light-background defaults. Tokens: `accent`, `track`, `face`,
`text`, `label`, `ticks`, `focusRing`, `fontMono`, `fontUI`.
Every skin takes the core props plus `size`, `label`, `showValue`, `unit`, `format`,
and per-part color props (`color`, `trackColor`, `faceColor`, `bodyColor`, …).
Every knob skin and fader takes the core props plus `size`, `label`, `showValue`,
`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

View file

@ -477,6 +477,16 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
onChangeEndRef.current?.(s.startValue, { source: 'drag' });
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;
let handled = true;
switch (e.key) {

View file

@ -32,6 +32,8 @@ export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
editable?: boolean;
/** Custom parser for typed input. */
parseValue?: (text: string) => number | null;
/** Render a hidden form input carrying the current value. */
name?: string;
className?: string;
style?: React.CSSProperties;
}
@ -57,6 +59,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
labelColor,
editable = false,
parseValue,
name,
className,
style,
...core
@ -256,6 +259,7 @@ export const Fader = React.forwardRef<HTMLDivElement, FaderProps>(function Fader
</>
)}
</svg>
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
</div>
{label && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>

View file

@ -27,6 +27,8 @@ export interface ImageKnobProps extends KnobCoreProps {
/** Show a floating value bubble above the knob while dragging. */
valueBubble?: boolean;
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). */
imageStyle?: React.CSSProperties;
className?: string;
@ -53,6 +55,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
parseValue,
valueBubble = false,
bubbleFormat,
name,
imageStyle,
className,
style,
@ -158,6 +161,7 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
{(bubbleFormat ?? (() => text))(knob.value)}
</div>
)}
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
</div>
{showValue && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>

View file

@ -45,6 +45,8 @@ export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
editable?: boolean;
/** Custom parser for typed input. */
parseValue?: (text: string) => number | null;
/** Render a hidden form input carrying the current value. */
name?: string;
className?: string;
style?: React.CSSProperties;
}
@ -68,6 +70,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
labelColor,
editable = false,
parseValue,
name,
className,
style,
...core
@ -231,6 +234,7 @@ export const LEDFader = React.forwardRef<HTMLDivElement, LEDFaderProps>(function
/>
{leds}
</svg>
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
</div>
{label && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>

View file

@ -51,6 +51,7 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
const accent = color ?? theme.accent ?? '#4cc2ff';
const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed);
const [focusVisible, setFocusVisible] = React.useState(false);
const on = isControlled ? (pressed as boolean) : internal;
const set = (next: boolean) => {
@ -85,7 +86,16 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
aria-pressed={on}
aria-label={aria['aria-label']}
data-pressed={on ? '' : undefined}
data-focus-visible={focusVisible ? '' : undefined}
className={className}
onFocus={e => {
try {
setFocusVisible(e.currentTarget.matches(':focus-visible'));
} catch {
setFocusVisible(true);
}
}}
onBlur={() => setFocusVisible(false)}
{...momentaryProps}
style={{
position: 'relative',
@ -103,9 +113,12 @@ export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
background: on
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
boxShadow: on
? '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)',
outline: 'none',
boxShadow: `${
on
? '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)'
}${focusVisible ? `, 0 0 0 2px ${theme.focusRing}` : ''}`,
color: on ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.28),