Initial release: dreamknob library + showcase

React + TypeScript knob/fader library with a headless interaction core
(rotary, relative-drag and track modes, wheel, keyboard, a11y), SVG
primitives, nine prebuilt skins, seven-segment display engine, and a
Vite showcase with gallery, live playground and API docs.
This commit is contained in:
Dreamodus 2026-07-12 14:26:52 -07:00
commit 60a02cb1db
46 changed files with 6186 additions and 0 deletions

42
apps/docs/src/App.tsx Normal file
View file

@ -0,0 +1,42 @@
import React from 'react';
import { ApiDocs } from './sections/ApiDocs';
import { Console } from './sections/Console';
import { Gallery } from './sections/Gallery';
import { Hero } from './sections/Hero';
import { Playground } from './sections/Playground';
const Logo: React.FC = () => (
<svg width="22" height="22" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="#18191f" stroke="#4cc2ff" strokeWidth="7" />
<line x1="50" y1="50" x2="50" y2="15" stroke="#4cc2ff" strokeWidth="9" strokeLinecap="round" />
</svg>
);
export const App: React.FC = () => (
<>
<nav className="nav">
<div className="container nav-inner">
<a className="nav-logo" href="#">
<Logo />
dreamknob
</a>
<div className="nav-links">
<a href="#gallery">Gallery</a>
<a href="#console">Demo</a>
<a href="#playground">Playground</a>
<a href="#docs">Docs</a>
</div>
</div>
</nav>
<Hero />
<Gallery />
<Console />
<Playground />
<ApiDocs />
<footer className="footer">
<div className="container">
dreamknob · MIT · built with a headless core, SVG and zero runtime dependencies
</div>
</footer>
</>
);

View file

@ -0,0 +1,43 @@
import React from 'react';
/** Tiny JSX-ish token colorizer — good enough for docs snippets. */
const highlight = (code: string): React.ReactNode[] => {
const pattern =
/(\/\/[^\n]*)|("[^"]*"|'[^']*'|`[^`]*`)|(\{[^{}]*\})|(<\/?[A-Za-z][\w.]*)|(\b\d+(?:\.\d+)?\b)|([a-zA-Z-]+)(?==)/g;
const out: React.ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = pattern.exec(code))) {
if (m.index > last) out.push(code.slice(last, m.index));
const [full, com, str, expr, tag, num, attr] = m;
if (com) out.push(<span key={key++} className="tok-com">{full}</span>);
else if (str) out.push(<span key={key++} className="tok-str">{full}</span>);
else if (expr) out.push(<span key={key++} className="tok-num">{full}</span>);
else if (tag) out.push(<span key={key++} className="tok-tag">{full}</span>);
else if (num) out.push(<span key={key++} className="tok-num">{full}</span>);
else if (attr) out.push(<span key={key++} className="tok-key">{full}</span>);
last = m.index + full.length;
}
out.push(code.slice(last));
return out;
};
export const CodeBlock: React.FC<{ code: string }> = ({ code }) => {
const [copied, setCopied] = React.useState(false);
return (
<div className="codeblock">
<button
className="copy"
onClick={() => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
>
{copied ? 'copied ✓' : 'copy'}
</button>
<pre>{highlight(code)}</pre>
</div>
);
};

10
apps/docs/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,148 @@
import React from 'react';
import { CodeBlock } from '../components/CodeBlock';
const Row: React.FC<{ name: string; type: string; def?: string; desc: string }> = ({
name,
type,
def,
desc,
}) => (
<tr>
<td>{name}</td>
<td>{type}</td>
<td>{def ?? '—'}</td>
<td>{desc}</td>
</tr>
);
const Table: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<table className="api-table">
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>{children}</tbody>
</table>
);
export const ApiDocs: React.FC = () => (
<section className="block" id="docs">
<div className="container prose">
<div className="section-kicker">Documentation</div>
<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>.
</p>
<h3 className="api-h">Core value &amp; interaction props</h3>
<Table>
<Row name="value" type="number" desc="Controlled value. Pair with onChange." />
<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="values" type="number[]" desc="Restrict to a discrete list, e.g. [0.25, 0.5, 1, 2, 4]. Overrides step." />
<Row name="steps" type="number" desc="Number of evenly spaced detents across the travel (selector knobs)." />
<Row name="taper" type="Taper" def="linearTaper" desc="Travel curve: linearTaper, logTaper (frequencies), powTaper(n) (gain), or your own." />
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during drags and wheel." />
<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 (one step per notch)." />
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
<Row name="onChange" type="(v: number) => void" desc="Fires for every distinct snapped value." />
<Row name="onChangeStart / onChangeEnd" type="(v: number) => void" desc="Gesture boundaries — ideal for undo history or parameter automation." />
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
</Table>
<h3 className="api-h">Keyboard &amp; gestures</h3>
<p className="api-note">
<code>/</code> and <code>/</code> step · <code>Shift</code>+arrows jump 10×
· <code>PageUp/PageDown</code> move 10% · <code>Home/End</code> jump to min/max ·
scroll wheel nudges (Shift = fine) · double-click resets · touch works via pointer
capture.
</p>
<h3 className="api-h">Prebuilt skins</h3>
<Table>
<Row name="<FlatKnob />" type="2D" desc="Modern arc knob. color, trackColor, faceColor, pointerColor, textColor, arcFrom ('min' | 'center'), arcThickness." />
<Row name="<MetalKnob />" type="3D" desc="Brushed aluminum. tone ('silver' | 'dark'), color, tickCount, showArc, indicatorColor." />
<Row name="<RubberKnob />" type="3D" desc="Soft-touch synth knob with glow halo. color, trackColor, arcFrom." />
<Row name="<VintageKnob />" type="analog" desc="Chicken-head pointer. bodyColor (cream/bakelite/any), scaleColor, scaleLabels." />
<Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals." />
<Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." />
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." />
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
<Row name="<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> plus every core prop
above.
</p>
<h3 className="api-h">Compose your own knob</h3>
<p className="api-note">
<code>&lt;Knob&gt;</code> wires up the interaction and provides a render context;
primitives draw into it. Use the built-ins or drop in raw SVG.
</p>
<CodeBlock
code={`import { Knob, Arc, Pointer, Ticks, KnobValue } from 'dreamknob'
<Knob size={96} min={-24} max={24} step={0.5} defaultValue={0} aria-label="Trim">
<Ticks count={25} activeColor="#ffd23e" />
<Arc radius={36} thickness={3} color="#ffd23e" from="center" />
<Pointer type="triangle" radius={33} length={9} width={8} color="#ffd23e" />
<KnobValue unit=" dB" />
</Knob>
// Or render-prop style for full control:
<Knob size={96} defaultValue={30} aria-label="Custom">
{({ normalized, angle, value, center }) => (
<circle cx={center} cy={center} r={30 + normalized * 8} fill="#4cc2ff33" />
)}
</Knob>`}
/>
<h3 className="api-h">Fully headless</h3>
<p className="api-note">
Skip our rendering entirely <code>useKnob</code> gives you the state and the
event bindings, you bring the DOM (or canvas, or WebGL).
</p>
<CodeBlock
code={`import { useKnob } from 'dreamknob'
function MyKnob() {
const { value, angle, isDragging, bind } = useKnob({
min: 0, max: 11, step: 0.1, defaultValue: 11,
interaction: 'rotary',
'aria-label': 'Volume',
})
return (
<div {...bind} style={{ ...bind.style, width: 80, height: 80 }}>
<div style={{ transform: \`rotate(\${angle}deg)\` }}>▲</div>
{isDragging && <span>{value}</span>}
</div>
)
}`}
/>
<h3 className="api-h">Precision &amp; number handling</h3>
<p className="api-note">
Values are snapped to <code>step</code> (anchored at <code>min</code>) and rounded
with a float-drift-safe algorithm, so <code>step=0.1</code> never produces{' '}
<code>0.30000000000000004</code>. Ranges can be negative, tiny (<code>1e-7</code>{' '}
steps) or huge; <code>decimals</code> caps emitted precision, and{' '}
<code>format</code> controls display independently.
</p>
</div>
</section>
);

View file

@ -0,0 +1,75 @@
import React from 'react';
import { Fader, FlatKnob, SegmentDisplay } from 'dreamknob';
const CHANNELS = [
{ name: 'Kick', color: '#4cc2ff', level: -4 },
{ name: 'Snare', color: '#3df2ad', level: -8 },
{ name: 'Bass', color: '#ff9640', level: -6 },
{ name: 'Keys', color: '#e44cff', level: -12 },
{ name: 'Vox', color: '#ffd23e', level: -3 },
];
const Channel: React.FC<{ name: string; color: string; level: number }> = ({
name,
color,
level,
}) => {
const [gain, setGain] = React.useState(level);
return (
<div className="channel">
<span className="ch-name">{name}</span>
<FlatKnob
size={56}
defaultValue={0}
min={-50}
max={50}
step={1}
arcFrom="center"
color={color}
showValue={false}
label="Pan"
aria-label={`${name} pan`}
/>
<FlatKnob
size={56}
defaultValue={20}
step={1}
color={color}
showValue={false}
label="Send"
aria-label={`${name} send`}
/>
<Fader
length={150}
breadth={38}
value={gain}
onChange={setGain}
min={-60}
max={12}
step={0.5}
color={color}
format={v => `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
aria-label={`${name} level`}
/>
<SegmentDisplay value={gain} digits={4} decimals={1} height={13} color={color} ghostOpacity={0.06} />
</div>
);
};
export const Console: React.FC = () => (
<section className="block" id="console">
<div className="container">
<div className="section-kicker">In context</div>
<h2 className="section-title">Build whole consoles</h2>
<p className="section-sub">
Controlled or uncontrolled, the components compose into full instrument panels.
This mixer is ~40 lines of JSX.
</p>
<div className="console">
{CHANNELS.map(ch => (
<Channel key={ch.name} {...ch} />
))}
</div>
</div>
</section>
);

View file

@ -0,0 +1,149 @@
import React from 'react';
import {
Arc,
Fader,
FlatKnob,
Knob,
KnobValue,
LEDFader,
LEDKnob,
MetalKnob,
NeonKnob,
Pointer,
RubberKnob,
SegmentDisplay,
SteppedKnob,
Ticks,
VintageKnob,
logTaper,
} from 'dreamknob';
const Card: React.FC<{
title: string;
desc: string;
tag: string;
children: React.ReactNode;
}> = ({ title, desc, tag, children }) => (
<div className="card">
<h3>{title}</h3>
<div className="card-desc">{desc}</div>
<div className="demo">{children}</div>
<span className="tag">{tag}</span>
</div>
);
export const Gallery: React.FC = () => (
<section className="block" id="gallery">
<div className="container">
<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.
</p>
<div className="grid">
<Card title="Flat" desc="Clean 2D arc knob for modern plugin UIs." tag="<FlatKnob />">
<FlatKnob size={86} defaultValue={64} label="Level" aria-label="Flat demo" />
<FlatKnob size={86} defaultValue={0} min={-50} max={50} arcFrom="center" color="#3df2ad" label="Pan" aria-label="Pan demo" />
<FlatKnob size={86} defaultValue={30} color="#ffd23e" trackColor="rgba(255,210,62,0.15)" label="Send" aria-label="Send demo" />
</Card>
<Card title="Metal" desc="Brushed aluminum with a knurled rim — 3D hi-fi feel." tag="<MetalKnob />">
<MetalKnob size={92} defaultValue={40} label="Input" aria-label="Metal demo" />
<MetalKnob size={92} defaultValue={75} tone="dark" color="#ff9640" label="Drive" aria-label="Dark metal demo" />
</Card>
<Card title="Rubber" desc="Soft-touch synth knob with a glowing halo." tag="<RubberKnob />">
<RubberKnob size={92} defaultValue={72} label="Cutoff" aria-label="Rubber demo" />
<RubberKnob size={92} defaultValue={30} color="#4cc2ff" label="Res" aria-label="Rubber resonance demo" />
</Card>
<Card title="Vintage" desc="Chicken-head bakelite over a printed scale." tag="<VintageKnob />">
<VintageKnob size={96} defaultValue={7} min={0} max={10} step={0.5} label="Volume" scaleLabels={['0', '', '', '', '', '5', '', '', '', '', '10']} aria-label="Vintage demo" />
<VintageKnob size={96} defaultValue={4} min={0} max={10} bodyColor="#26221f" label="Tone" aria-label="Bakelite demo" />
</Card>
<Card title="LED" desc="Segmented ring + true seven-segment readout." tag="<LEDKnob />">
<LEDKnob size={96} defaultValue={120} min={40} max={240} step={1} label="BPM" aria-label="LED demo" />
<LEDKnob size={96} defaultValue={-12.5} min={-60} max={0} step={0.5} digits={4} color="#ff4d6b" label="Thresh" aria-label="Threshold demo" />
</Card>
<Card title="Neon" desc="A glowing arc for dark, futuristic interfaces." tag="<NeonKnob />">
<NeonKnob size={90} defaultValue={35} label="Space" aria-label="Neon demo" />
<NeonKnob size={90} defaultValue={80} color="#3df2ad" label="Shine" aria-label="Neon green demo" />
</Card>
<Card title="Stepped" desc="Detented selector with named or numeric positions." tag="<SteppedKnob />">
<SteppedKnob size={92} positions={['LP', 'BP', 'HP', 'NT']} defaultValue={0} label="Filter" aria-label="Filter type demo" />
<SteppedKnob size={92} min={-24} max={24} steps={9} color="#4cc2ff" defaultValue={0} label="Semi" aria-label="Semitones demo" />
</Card>
<Card title="Fader" desc="Absolute-position linear control, both orientations." tag="<Fader />">
<Fader length={130} defaultValue={-6} min={-60} max={12} step={0.5} unit=" dB" label="Main" aria-label="Fader demo" />
<Fader orientation="horizontal" length={150} defaultValue={35} step={1} color="#e44cff" label="X-Fade" aria-label="Crossfade demo" />
</Card>
<Card title="LED fader" desc="Segmented meter-style fader with color zones." tag="<LEDFader />">
<LEDFader
length={130}
defaultValue={70}
step={1}
zones={[
{ upTo: 0.6, color: '#3df2ad' },
{ upTo: 0.85, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
]}
label="Level"
aria-label="LED fader demo"
/>
<LEDFader
orientation="horizontal"
length={140}
defaultValue={40}
step={1}
color="#4cc2ff"
label="Pos"
aria-label="Horizontal LED fader demo"
/>
</Card>
<Card title="Segment display" desc="The seven-segment engine, standalone." tag="<SegmentDisplay />">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' }}>
<SegmentDisplay value={128.5} digits={4} decimals={1} />
<SegmentDisplay value={-42} digits={4} color="#ff4d6b" />
</div>
</Card>
<Card title="Log taper" desc="Audio-true frequency sweep: equal ratios per turn." tag="taper={logTaper}">
<FlatKnob
size={92}
min={20}
max={20000}
defaultValue={632}
taper={logTaper}
decimals={0}
color="#3df2ad"
format={v => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
label="Freq"
aria-label="Frequency demo"
/>
</Card>
<Card title="Compose your own" desc="Primitives + headless core = any design you want." tag="<Knob> + primitives">
<Knob size={96} defaultValue={42} aria-label="Composed demo">
<Ticks count={28} radius={47} length={5} width={1.5} color="rgba(255,255,255,0.12)" activeColor="#ffd23e" />
<Arc radius={36} thickness={2.5} color="#ffd23e" trackColor="rgba(255,255,255,0.08)" cap="butt" />
<Pointer type="triangle" radius={33} length={9} width={8} color="#ffd23e" />
<KnobValue color="#fff" fontSize={15} />
</Knob>
</Card>
<Card title="Vertical-drag mode" desc="Prefer the up/down plugin feel? One prop." tag="interaction='vertical'">
<RubberKnob size={92} defaultValue={50} interaction="vertical" color="#3df2ad" label="Depth" showValue aria-label="Vertical drag demo" />
</Card>
</div>
</div>
</section>
);

View file

@ -0,0 +1,74 @@
import React from 'react';
import {
Fader,
FlatKnob,
LEDFader,
LEDKnob,
MetalKnob,
NeonKnob,
RubberKnob,
SteppedKnob,
VintageKnob,
} from 'dreamknob';
export const Hero: React.FC = () => {
const [copied, setCopied] = React.useState(false);
return (
<header className="hero">
<div className="container">
<h1>
Knobs that feel like <span className="grad">hardware.</span>
</h1>
<p className="tagline">
<b>dreamknob</b> is a feature-full React + TypeScript knob &amp; fader library.
Rotary, linear, 2D, 3D, digital and analog with a headless core, any number
range, any decimal precision, and colors you fully control.
</p>
<div className="install">
<span>pnpm add dreamknob</span>
<button
onClick={() => {
navigator.clipboard.writeText('pnpm add dreamknob');
setCopied(true);
setTimeout(() => setCopied(false), 1400);
}}
>
{copied ? '✓' : 'copy'}
</button>
</div>
<div className="rack">
<div className="rack-screws">
<span /><span /><span /><span />
</div>
<div className="rack-row">
<MetalKnob size={96} label="Gain" defaultValue={64} color="#4cc2ff" aria-label="Gain" />
<VintageKnob size={96} label="Drive" defaultValue={7} min={0} max={10} step={0.1} aria-label="Drive" />
<RubberKnob size={96} label="Cutoff" defaultValue={72} color="#ff9640" aria-label="Cutoff" />
<LEDKnob size={96} label="Rate" defaultValue={120} min={40} max={240} step={1} color="#3df2ad" aria-label="Rate" />
<NeonKnob size={96} label="Space" defaultValue={35} color="#e44cff" aria-label="Space" />
<SteppedKnob size={96} label="Mode" positions={['LP', 'BP', 'HP', 'NT']} defaultValue={0} aria-label="Mode" />
<FlatKnob size={96} label="Mix" defaultValue={50} unit="%" step={1} arcFrom="center" aria-label="Mix" />
<Fader length={128} breadth={40} label="Out" defaultValue={-6} min={-60} max={12} step={0.5} unit=" dB" aria-label="Output level" />
<LEDFader
length={128}
label="Level"
defaultValue={70}
step={1}
zones={[
{ upTo: 0.6, color: '#3df2ad' },
{ upTo: 0.85, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
]}
aria-label="Level meter"
/>
</div>
<div className="rack-hint">
drag to rotate · scroll to nudge · <kbd></kbd> for fine control · double-click to
reset · arrow keys work too
</div>
</div>
</div>
</header>
);
};

View file

@ -0,0 +1,275 @@
import React from 'react';
import {
Fader,
FlatKnob,
LEDFader,
LEDKnob,
MetalKnob,
NeonKnob,
RubberKnob,
SteppedKnob,
VintageKnob,
logTaper,
powTaper,
type InteractionMode,
} from 'dreamknob';
import { CodeBlock } from '../components/CodeBlock';
type SkinId =
| 'flat'
| 'metal'
| 'rubber'
| 'vintage'
| 'led'
| 'neon'
| 'stepped'
| 'fader'
| 'ledfader';
const SKINS: Record<SkinId, string> = {
flat: 'FlatKnob',
metal: 'MetalKnob',
rubber: 'RubberKnob',
vintage: 'VintageKnob',
led: 'LEDKnob',
neon: 'NeonKnob',
stepped: 'SteppedKnob',
fader: 'Fader',
ledfader: 'LEDFader',
};
interface Config {
skin: SkinId;
size: number;
min: number;
max: number;
step: number;
interaction: InteractionMode;
angleRange: number;
taper: 'linear' | 'log' | 'pow2';
color: string;
label: string;
unit: string;
showValue: boolean;
arcFrom: 'min' | 'center';
}
const DEFAULTS: Config = {
skin: 'flat',
size: 160,
min: 0,
max: 100,
step: 0.5,
interaction: 'rotary',
angleRange: 270,
taper: 'linear',
color: '#4cc2ff',
label: 'Level',
unit: '',
showValue: true,
arcFrom: 'min',
};
const buildCode = (c: Config, controlled: boolean): string => {
const props: string[] = [];
if (c.skin === 'fader' || c.skin === 'ledfader') 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}}`);
if (c.min !== 0) props.push(`min={${c.min}}`);
if (c.max !== 100) props.push(`max={${c.max}}`);
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}}`);
props.push(`color="${c.color}"`);
if (c.label) props.push(`label="${c.label}"`);
if (c.unit) props.push(`unit="${c.unit}"`);
if (c.skin === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
if (c.skin === 'stepped') props.push(`steps={5}`);
const name = SKINS[c.skin];
const body = props.map(p => ` ${p}`).join('\n');
const imports = [name, c.taper === 'log' ? 'logTaper' : '', c.taper === 'pow2' ? 'powTaper' : '']
.filter(Boolean)
.join(', ');
return `import { ${imports} } from 'dreamknob'\n\n<${name}\n${body}\n/>`;
};
const num = (v: string, fallback: number) => {
const n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
};
export const Playground: React.FC = () => {
const [cfg, setCfg] = React.useState<Config>(DEFAULTS);
const [value, setValue] = React.useState(50);
const [endValue, setEndValue] = React.useState<number | null>(null);
const set = <K extends keyof Config>(key: K, v: Config[K]) => setCfg(p => ({ ...p, [key]: v }));
const taper = cfg.taper === 'log' ? logTaper : cfg.taper === 'pow2' ? powTaper(2) : undefined;
const logInvalid = cfg.taper === 'log' && cfg.min <= 0;
const common = {
value,
onChange: setValue,
onChangeEnd: setEndValue,
min: cfg.min,
max: cfg.max,
step: cfg.step > 0 ? cfg.step : undefined,
taper: logInvalid ? undefined : taper,
interaction: cfg.interaction,
angleRange: cfg.angleRange,
color: cfg.color,
label: cfg.label || undefined,
unit: cfg.unit || undefined,
showValue: cfg.showValue,
'aria-label': cfg.label || 'playground knob',
};
const preview = (() => {
switch (cfg.skin) {
case 'flat':
return <FlatKnob size={cfg.size} arcFrom={cfg.arcFrom} {...common} />;
case 'metal':
return <MetalKnob size={cfg.size} {...common} />;
case 'rubber':
return <RubberKnob size={cfg.size} {...common} />;
case 'vintage':
return <VintageKnob size={cfg.size} {...common} />;
case 'led':
return <LEDKnob size={cfg.size} {...common} />;
case 'neon':
return <NeonKnob size={cfg.size} {...common} />;
case 'stepped':
return <SteppedKnob size={cfg.size} steps={5} {...common} />;
case 'fader':
return <Fader length={cfg.size * 1.4} breadth={48} {...common} />;
case 'ledfader':
return <LEDFader length={cfg.size * 1.4} breadth={40} {...common} />;
}
})();
return (
<section className="block" id="playground">
<div className="container">
<div className="section-kicker">Playground</div>
<h2 className="section-title">Turn it up. Then copy the code.</h2>
<p className="section-sub">
Every prop is live. The generated JSX below always matches what you're touching.
</p>
<div className="playground">
<div className="controls">
<div className="control">
<label>Component</label>
<select value={cfg.skin} onChange={e => set('skin', e.target.value as SkinId)}>
{Object.entries(SKINS).map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
</div>
<div className="control">
<label>Size · {cfg.size}px</label>
<input
type="range"
min={60}
max={260}
value={cfg.size}
onChange={e => set('size', +e.target.value)}
/>
</div>
<div className="control-row">
<div className="control">
<label>Min</label>
<input type="number" value={cfg.min} onChange={e => set('min', num(e.target.value, 0))} />
</div>
<div className="control">
<label>Max</label>
<input type="number" value={cfg.max} onChange={e => set('max', num(e.target.value, 100))} />
</div>
</div>
<div className="control-row">
<div className="control">
<label>Step</label>
<input
type="number"
step="any"
min={0}
value={cfg.step}
onChange={e => set('step', num(e.target.value, 0))}
/>
</div>
<div className="control">
<label>Sweep °</label>
<input
type="number"
min={45}
max={360}
value={cfg.angleRange}
onChange={e => set('angleRange', num(e.target.value, 270))}
/>
</div>
</div>
<div className="control">
<label>Drag feel</label>
<select
value={cfg.interaction}
onChange={e => set('interaction', e.target.value as InteractionMode)}
>
<option value="rotary">rotary (grab &amp; turn)</option>
<option value="vertical">vertical drag</option>
<option value="horizontal">horizontal drag</option>
<option value="both">vertical + horizontal</option>
</select>
</div>
<div className="control">
<label>Taper</label>
<select value={cfg.taper} onChange={e => set('taper', e.target.value as Config['taper'])}>
<option value="linear">linear</option>
<option value="log">logarithmic (min &gt; 0)</option>
<option value="pow2">power (x²)</option>
</select>
{logInvalid && (
<span style={{ fontSize: 11, color: '#ff9640' }}>log taper needs min &gt; 0 using linear</span>
)}
</div>
<div className="control">
<label>Accent color</label>
<input type="color" value={cfg.color} onChange={e => set('color', e.target.value)} />
</div>
<div className="control-row">
<div className="control">
<label>Label</label>
<input type="text" value={cfg.label} onChange={e => set('label', e.target.value)} />
</div>
<div className="control">
<label>Unit</label>
<input type="text" value={cfg.unit} placeholder="dB, %, Hz…" onChange={e => set('unit', e.target.value)} />
</div>
</div>
<div className="control">
<label className="check">
<input
type="checkbox"
checked={cfg.showValue}
onChange={e => set('showValue', e.target.checked)}
/>
show value readout
</label>
</div>
</div>
<div className="stage">
<div className="preview">{preview}</div>
<div className="readout">
<span>onChange <b>{value}</b></span>
<span>onChangeEnd <b>{endValue ?? '—'}</b></span>
</div>
<CodeBlock code={buildCode(cfg, true)} />
</div>
</div>
</div>
</section>
);
};

521
apps/docs/src/styles.css Normal file
View file

@ -0,0 +1,521 @@
:root {
--bg: #0a0b0e;
--bg-raised: #101218;
--panel: #13151c;
--panel-2: #171a22;
--line: rgba(255, 255, 255, 0.07);
--line-strong: rgba(255, 255, 255, 0.13);
--text: #e8eaf0;
--text-dim: #9aa0ad;
--text-faint: #5d6370;
--accent: #4cc2ff;
--accent-2: #3df2ad;
--accent-3: #ff9640;
--accent-4: #e44cff;
--font-ui: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 72px;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
line-height: 1.6;
}
::selection {
background: rgba(76, 194, 255, 0.3);
}
.container {
max-width: 1160px;
margin: 0 auto;
padding: 0 24px;
}
/* ---- nav ---- */
.nav {
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(14px);
background: rgba(10, 11, 14, 0.75);
border-bottom: 1px solid var(--line);
}
.nav-inner {
display: flex;
align-items: center;
gap: 28px;
height: 60px;
}
.nav-logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
font-size: 17px;
letter-spacing: -0.02em;
color: var(--text);
text-decoration: none;
}
.nav-logo svg {
display: block;
}
.nav-links {
display: flex;
gap: 4px;
margin-left: auto;
}
.nav-links a {
color: var(--text-dim);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
padding: 6px 12px;
border-radius: 8px;
transition: color 0.15s, background 0.15s;
}
.nav-links a:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
/* ---- hero ---- */
.hero {
padding: 84px 0 40px;
text-align: center;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: -40% -20% auto;
height: 130%;
background:
radial-gradient(600px 300px at 30% 20%, rgba(76, 194, 255, 0.12), transparent 70%),
radial-gradient(600px 300px at 70% 10%, rgba(228, 76, 255, 0.08), transparent 70%);
pointer-events: none;
}
.hero h1 {
font-size: clamp(40px, 6vw, 64px);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.05;
position: relative;
}
.hero h1 .grad {
background: linear-gradient(90deg, var(--accent), var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p.tagline {
margin: 18px auto 0;
max-width: 640px;
color: var(--text-dim);
font-size: 17px;
position: relative;
}
.hero .install {
margin: 26px auto 0;
display: inline-flex;
align-items: center;
gap: 12px;
background: var(--panel);
border: 1px solid var(--line-strong);
padding: 10px 16px;
border-radius: 10px;
font-family: var(--font-mono);
font-size: 14px;
color: var(--accent-2);
position: relative;
}
.hero .install button {
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
font-size: 13px;
padding: 2px 6px;
}
.hero .install button:hover {
color: var(--text);
}
/* ---- rack panel (hero demo) ---- */
.rack {
margin: 48px auto 0;
max-width: 980px;
border-radius: 16px;
border: 1px solid var(--line-strong);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 40%),
linear-gradient(180deg, #191b22, #101116);
box-shadow:
0 30px 80px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
padding: 30px 34px 24px;
position: relative;
}
.rack-screws span {
position: absolute;
width: 11px;
height: 11px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #6a6d78, #23252c 75%);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.8);
}
.rack-screws span:nth-child(1) { top: 12px; left: 12px; }
.rack-screws span:nth-child(2) { top: 12px; right: 12px; }
.rack-screws span:nth-child(3) { bottom: 12px; left: 12px; }
.rack-screws span:nth-child(4) { bottom: 12px; right: 12px; }
.rack-row {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 34px;
flex-wrap: wrap;
}
.rack-hint {
margin-top: 18px;
font-size: 12px;
color: var(--text-faint);
text-align: center;
}
.rack-hint kbd {
font-family: var(--font-mono);
font-size: 11px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid var(--line-strong);
border-radius: 4px;
padding: 1px 5px;
}
/* ---- sections ---- */
section.block {
padding: 72px 0 12px;
}
.section-kicker {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
h2.section-title {
font-size: 32px;
font-weight: 800;
letter-spacing: -0.03em;
margin: 6px 0 8px;
}
.section-sub {
color: var(--text-dim);
max-width: 640px;
margin-bottom: 34px;
}
/* ---- gallery ---- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 18px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 22px;
transition: border-color 0.2s, transform 0.2s;
}
.card:hover {
border-color: var(--line-strong);
}
.card h3 {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.card .card-desc {
font-size: 13px;
color: var(--text-dim);
margin: 2px 0 18px;
}
.card .demo {
display: flex;
align-items: center;
justify-content: center;
gap: 22px;
min-height: 130px;
flex-wrap: wrap;
border-radius: 10px;
background:
radial-gradient(400px 160px at 50% 0%, rgba(255, 255, 255, 0.03), transparent),
var(--bg-raised);
border: 1px solid var(--line);
padding: 18px 10px;
}
.card .tag {
display: inline-block;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-faint);
background: rgba(255, 255, 255, 0.05);
padding: 2px 8px;
border-radius: 999px;
margin-top: 14px;
}
/* ---- console demo ---- */
.console {
border-radius: 16px;
border: 1px solid var(--line-strong);
background: linear-gradient(180deg, #16181f, #0e0f14);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
padding: 26px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.channel {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 18px 18px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid var(--line);
min-width: 128px;
}
.channel .ch-name {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
color: var(--text-dim);
text-transform: uppercase;
}
/* ---- playground ---- */
.playground {
display: grid;
grid-template-columns: 300px 1fr;
gap: 18px;
align-items: start;
}
@media (max-width: 860px) {
.playground {
grid-template-columns: 1fr;
}
}
.controls {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
position: sticky;
top: 76px;
}
.control {
display: flex;
flex-direction: column;
gap: 5px;
}
.control > label {
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
}
.control input[type='text'],
.control input[type='number'],
.control select {
background: var(--bg-raised);
border: 1px solid var(--line-strong);
color: var(--text);
border-radius: 8px;
padding: 7px 10px;
font-size: 13.5px;
font-family: var(--font-mono);
outline: none;
width: 100%;
}
.control input:focus,
.control select:focus {
border-color: var(--accent);
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.control input[type='range'] {
accent-color: var(--accent);
}
.control input[type='color'] {
width: 100%;
height: 32px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--bg-raised);
padding: 3px;
cursor: pointer;
}
.control .check {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-dim);
text-transform: none;
letter-spacing: 0;
}
.stage {
display: flex;
flex-direction: column;
gap: 18px;
}
.preview {
background:
radial-gradient(500px 240px at 50% 10%, rgba(76, 194, 255, 0.05), transparent),
var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
padding: 30px;
}
.readout {
display: flex;
gap: 20px;
justify-content: center;
align-items: center;
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--text-faint);
}
.readout b {
color: var(--accent-2);
font-weight: 500;
}
/* ---- code ---- */
.codeblock {
position: relative;
background: #0d0e12;
border: 1px solid var(--line);
border-radius: 12px;
overflow: auto;
}
.codeblock pre {
padding: 18px 20px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.65;
color: #c8cede;
white-space: pre;
}
.codeblock .copy {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--line-strong);
color: var(--text-dim);
font-size: 12px;
border-radius: 7px;
padding: 4px 10px;
cursor: pointer;
}
.codeblock .copy:hover {
color: var(--text);
}
.tok-key { color: #7dd3fc; }
.tok-str { color: #86efac; }
.tok-num { color: #fca5a5; }
.tok-tag { color: #c4b5fd; }
.tok-com { color: #565d6b; font-style: italic; }
/* ---- docs / api ---- */
.api-table {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
margin: 14px 0 30px;
}
.api-table th {
text-align: left;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
padding: 8px 12px;
border-bottom: 1px solid var(--line-strong);
}
.api-table td {
padding: 9px 12px;
border-bottom: 1px solid var(--line);
vertical-align: top;
color: var(--text-dim);
}
.api-table td:first-child {
font-family: var(--font-mono);
color: var(--accent);
white-space: nowrap;
font-size: 12.5px;
}
.api-table td:nth-child(2) {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent-3);
}
.api-table code,
.prose code {
font-family: var(--font-mono);
font-size: 0.9em;
background: rgba(255, 255, 255, 0.06);
padding: 1px 5px;
border-radius: 4px;
}
h3.api-h {
font-size: 19px;
font-weight: 700;
margin: 38px 0 4px;
letter-spacing: -0.01em;
}
.api-note {
font-size: 13.5px;
color: var(--text-dim);
max-width: 720px;
}
.footer {
margin-top: 80px;
border-top: 1px solid var(--line);
padding: 30px 0 40px;
color: var(--text-faint);
font-size: 13px;
text-align: center;
}
.footer a {
color: var(--text-dim);
}