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:
commit
60a02cb1db
46 changed files with 6186 additions and 0 deletions
22
apps/docs/index.html
Normal file
22
apps/docs/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dreamknob — studio-grade React knobs & faders</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='42' fill='%2318191f' stroke='%234cc2ff' stroke-width='6'/%3E%3Cline x1='50' y1='50' x2='50' y2='16' stroke='%234cc2ff' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
apps/docs/package.json
Normal file
25
apps/docs/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "docs",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"dreamknob": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3"
|
||||
}
|
||||
}
|
||||
42
apps/docs/src/App.tsx
Normal file
42
apps/docs/src/App.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
43
apps/docs/src/components/CodeBlock.tsx
Normal file
43
apps/docs/src/components/CodeBlock.tsx
Normal 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
10
apps/docs/src/main.tsx
Normal 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>,
|
||||
);
|
||||
148
apps/docs/src/sections/ApiDocs.tsx
Normal file
148
apps/docs/src/sections/ApiDocs.tsx
Normal 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><Knob></code>, every prebuilt skin and{' '}
|
||||
<code><Fader></code>.
|
||||
</p>
|
||||
|
||||
<h3 className="api-h">Core value & 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 & 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><Knob></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 & 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>
|
||||
);
|
||||
75
apps/docs/src/sections/Console.tsx
Normal file
75
apps/docs/src/sections/Console.tsx
Normal 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>
|
||||
);
|
||||
149
apps/docs/src/sections/Gallery.tsx
Normal file
149
apps/docs/src/sections/Gallery.tsx
Normal 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>
|
||||
);
|
||||
74
apps/docs/src/sections/Hero.tsx
Normal file
74
apps/docs/src/sections/Hero.tsx
Normal 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 & 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>
|
||||
);
|
||||
};
|
||||
275
apps/docs/src/sections/Playground.tsx
Normal file
275
apps/docs/src/sections/Playground.tsx
Normal 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 & 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 > 0)</option>
|
||||
<option value="pow2">power (x²)</option>
|
||||
</select>
|
||||
{logInvalid && (
|
||||
<span style={{ fontSize: 11, color: '#ff9640' }}>log taper needs min > 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
521
apps/docs/src/styles.css
Normal 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);
|
||||
}
|
||||
21
apps/docs/tsconfig.json
Normal file
21
apps/docs/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"dreamknob": ["../../packages/dreamknob/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
13
apps/docs/vite.config.ts
Normal file
13
apps/docs/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Use library sources directly for instant HMR while developing.
|
||||
dreamknob: fileURLToPath(new URL('../../packages/dreamknob/src/index.ts', import.meta.url)),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue