From 60a02cb1dba770f0f4e8de2bd87a979806c6b9da Mon Sep 17 00:00:00 2001 From: Dreamodus Date: Sun, 12 Jul 2026 14:26:52 -0700 Subject: [PATCH] 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. --- .gitignore | 6 + README.md | 42 + apps/docs/index.html | 22 + apps/docs/package.json | 25 + apps/docs/src/App.tsx | 42 + apps/docs/src/components/CodeBlock.tsx | 43 + apps/docs/src/main.tsx | 10 + apps/docs/src/sections/ApiDocs.tsx | 148 ++ apps/docs/src/sections/Console.tsx | 75 + apps/docs/src/sections/Gallery.tsx | 149 ++ apps/docs/src/sections/Hero.tsx | 74 + apps/docs/src/sections/Playground.tsx | 275 +++ apps/docs/src/styles.css | 521 +++++ apps/docs/tsconfig.json | 21 + apps/docs/vite.config.ts | 13 + package.json | 12 + packages/dreamknob/README.md | 110 + packages/dreamknob/package.json | 51 + packages/dreamknob/src/components/Knob.tsx | 72 + packages/dreamknob/src/core/context.ts | 23 + packages/dreamknob/src/core/math.test.ts | 122 + packages/dreamknob/src/core/math.ts | 156 ++ packages/dreamknob/src/core/types.ts | 82 + .../dreamknob/src/digital/SegmentDisplay.tsx | 88 + packages/dreamknob/src/digital/segments.tsx | 122 + packages/dreamknob/src/hooks/useKnob.ts | 350 +++ packages/dreamknob/src/index.ts | 44 + packages/dreamknob/src/primitives/Arc.tsx | 70 + packages/dreamknob/src/primitives/Face.tsx | 13 + .../dreamknob/src/primitives/GlowFilter.tsx | 35 + packages/dreamknob/src/primitives/Pointer.tsx | 73 + packages/dreamknob/src/primitives/Text.tsx | 97 + packages/dreamknob/src/primitives/Ticks.tsx | 55 + packages/dreamknob/src/skins/Fader.tsx | 193 ++ packages/dreamknob/src/skins/FlatKnob.tsx | 69 + packages/dreamknob/src/skins/LEDFader.tsx | 192 ++ packages/dreamknob/src/skins/LEDKnob.tsx | 131 ++ packages/dreamknob/src/skins/MetalKnob.tsx | 122 + packages/dreamknob/src/skins/NeonKnob.tsx | 86 + packages/dreamknob/src/skins/RubberKnob.tsx | 104 + packages/dreamknob/src/skins/SteppedKnob.tsx | 94 + packages/dreamknob/src/skins/VintageKnob.tsx | 156 ++ packages/dreamknob/src/skins/shared.ts | 23 + packages/dreamknob/tsconfig.json | 19 + pnpm-lock.yaml | 1953 +++++++++++++++++ pnpm-workspace.yaml | 3 + 46 files changed, 6186 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/docs/index.html create mode 100644 apps/docs/package.json create mode 100644 apps/docs/src/App.tsx create mode 100644 apps/docs/src/components/CodeBlock.tsx create mode 100644 apps/docs/src/main.tsx create mode 100644 apps/docs/src/sections/ApiDocs.tsx create mode 100644 apps/docs/src/sections/Console.tsx create mode 100644 apps/docs/src/sections/Gallery.tsx create mode 100644 apps/docs/src/sections/Hero.tsx create mode 100644 apps/docs/src/sections/Playground.tsx create mode 100644 apps/docs/src/styles.css create mode 100644 apps/docs/tsconfig.json create mode 100644 apps/docs/vite.config.ts create mode 100644 package.json create mode 100644 packages/dreamknob/README.md create mode 100644 packages/dreamknob/package.json create mode 100644 packages/dreamknob/src/components/Knob.tsx create mode 100644 packages/dreamknob/src/core/context.ts create mode 100644 packages/dreamknob/src/core/math.test.ts create mode 100644 packages/dreamknob/src/core/math.ts create mode 100644 packages/dreamknob/src/core/types.ts create mode 100644 packages/dreamknob/src/digital/SegmentDisplay.tsx create mode 100644 packages/dreamknob/src/digital/segments.tsx create mode 100644 packages/dreamknob/src/hooks/useKnob.ts create mode 100644 packages/dreamknob/src/index.ts create mode 100644 packages/dreamknob/src/primitives/Arc.tsx create mode 100644 packages/dreamknob/src/primitives/Face.tsx create mode 100644 packages/dreamknob/src/primitives/GlowFilter.tsx create mode 100644 packages/dreamknob/src/primitives/Pointer.tsx create mode 100644 packages/dreamknob/src/primitives/Text.tsx create mode 100644 packages/dreamknob/src/primitives/Ticks.tsx create mode 100644 packages/dreamknob/src/skins/Fader.tsx create mode 100644 packages/dreamknob/src/skins/FlatKnob.tsx create mode 100644 packages/dreamknob/src/skins/LEDFader.tsx create mode 100644 packages/dreamknob/src/skins/LEDKnob.tsx create mode 100644 packages/dreamknob/src/skins/MetalKnob.tsx create mode 100644 packages/dreamknob/src/skins/NeonKnob.tsx create mode 100644 packages/dreamknob/src/skins/RubberKnob.tsx create mode 100644 packages/dreamknob/src/skins/SteppedKnob.tsx create mode 100644 packages/dreamknob/src/skins/VintageKnob.tsx create mode 100644 packages/dreamknob/src/skins/shared.ts create mode 100644 packages/dreamknob/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e2c18b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.local +.DS_Store +*.tsbuildinfo +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6909549 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# dreamknob + +A feature-full, studio-grade **React + TypeScript knob & fader library** with a headless +interaction core — plus a showcase/documentation site. + +``` +pnpm install # install everything +pnpm dev # run the showcase at http://localhost:5173 +pnpm test # library unit tests +pnpm build # build library + docs +``` + +## Workspace layout + +| Path | What it is | +| --- | --- | +| `packages/dreamknob` | The library. Zero runtime dependencies, ESM + CJS + `.d.ts`. | +| `apps/docs` | Vite showcase: gallery, live playground with code generation, API docs. | + +## The library in 10 seconds + +```tsx +import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob' + + + + +``` + +- **Feel**: grab-and-rotate rotary tracking (rc-knob style), or relative vertical / + horizontal drag (DAW plugin style), or absolute track dragging for faders. Scroll-wheel + nudge, full keyboard support, Shift for fine control, double-click reset, touch via + pointer capture, proper `role="slider"` accessibility. +- **Numbers**: any range (negative, fractional, huge), decimal `step` with float-drift-safe + rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log / + power / custom tapers. +- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`, + `SteppedKnob`, `Fader`, `LEDFader`, `SegmentDisplay` — every color themeable per instance. +- **Composable**: `` + primitives (`Arc`, `Pointer`, `Ticks`, `Face`, `KnobValue`, + `KnobLabel`) for custom designs, or go fully headless with `useKnob`. + +See `packages/dreamknob/README.md` and the docs app for the full API. diff --git a/apps/docs/index.html b/apps/docs/index.html new file mode 100644 index 0000000..a1c9b5e --- /dev/null +++ b/apps/docs/index.html @@ -0,0 +1,22 @@ + + + + + + dreamknob — studio-grade React knobs & faders + + + + + + +
+ + + diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..3e2312a --- /dev/null +++ b/apps/docs/package.json @@ -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" + } +} diff --git a/apps/docs/src/App.tsx b/apps/docs/src/App.tsx new file mode 100644 index 0000000..dc47f1c --- /dev/null +++ b/apps/docs/src/App.tsx @@ -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 = () => ( + + + + +); + +export const App: React.FC = () => ( + <> + + + + + + +
+
+ dreamknob · MIT · built with a headless core, SVG and zero runtime dependencies +
+
+ +); diff --git a/apps/docs/src/components/CodeBlock.tsx b/apps/docs/src/components/CodeBlock.tsx new file mode 100644 index 0000000..01fbf26 --- /dev/null +++ b/apps/docs/src/components/CodeBlock.tsx @@ -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({full}); + else if (str) out.push({full}); + else if (expr) out.push({full}); + else if (tag) out.push({full}); + else if (num) out.push({full}); + else if (attr) out.push({full}); + 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 ( +
+ +
{highlight(code)}
+
+ ); +}; diff --git a/apps/docs/src/main.tsx b/apps/docs/src/main.tsx new file mode 100644 index 0000000..ed0dc63 --- /dev/null +++ b/apps/docs/src/main.tsx @@ -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( + + + , +); diff --git a/apps/docs/src/sections/ApiDocs.tsx b/apps/docs/src/sections/ApiDocs.tsx new file mode 100644 index 0000000..66a8b50 --- /dev/null +++ b/apps/docs/src/sections/ApiDocs.tsx @@ -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, +}) => ( + + {name} + {type} + {def ?? '—'} + {desc} + +); + +const Table: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + + + + + + + + + {children} +
PropTypeDefaultDescription
+); + +export const ApiDocs: React.FC = () => ( +
+
+
Documentation
+

API reference

+

+ Everything is built on one interaction core. The props below are shared by{' '} + useKnob, <Knob>, every prebuilt skin and{' '} + <Fader>. +

+ +

Core value & interaction props

+ + + + + + + + + + + + + + + + + + + +
+ +

Keyboard & gestures

+

+ ↑/→ and ↓/← step · Shift+arrows jump 10× + · PageUp/PageDown move 10% · Home/End jump to min/max · + scroll wheel nudges (Shift = fine) · double-click resets · touch works via pointer + capture. +

+ +

Prebuilt skins

+ + + + + + + + + + + +
+

+ All skins also take label, showValue, unit,{' '} + format, className, style plus every core prop + above. +

+ +

Compose your own knob

+

+ <Knob> wires up the interaction and provides a render context; + primitives draw into it. Use the built-ins or drop in raw SVG. +

+ + + + + + + +// Or render-prop style for full control: + + {({ normalized, angle, value, center }) => ( + + )} +`} + /> + +

Fully headless

+

+ Skip our rendering entirely — useKnob gives you the state and the + event bindings, you bring the DOM (or canvas, or WebGL). +

+ +
+ {isDragging && {value}} +
+ ) +}`} + /> + +

Precision & number handling

+

+ Values are snapped to step (anchored at min) and rounded + with a float-drift-safe algorithm, so step=0.1 never produces{' '} + 0.30000000000000004. Ranges can be negative, tiny (1e-7{' '} + steps) or huge; decimals caps emitted precision, and{' '} + format controls display independently. +

+ +
+); diff --git a/apps/docs/src/sections/Console.tsx b/apps/docs/src/sections/Console.tsx new file mode 100644 index 0000000..c0e3deb --- /dev/null +++ b/apps/docs/src/sections/Console.tsx @@ -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 ( +
+ {name} + + + `${v > 0 ? '+' : ''}${v.toFixed(1)}`} + aria-label={`${name} level`} + /> + +
+ ); +}; + +export const Console: React.FC = () => ( +
+
+
In context
+

Build whole consoles

+

+ Controlled or uncontrolled, the components compose into full instrument panels. + This mixer is ~40 lines of JSX. +

+
+ {CHANNELS.map(ch => ( + + ))} +
+
+
+); diff --git a/apps/docs/src/sections/Gallery.tsx b/apps/docs/src/sections/Gallery.tsx new file mode 100644 index 0000000..c73a23e --- /dev/null +++ b/apps/docs/src/sections/Gallery.tsx @@ -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 }) => ( +
+

{title}

+
{desc}
+
{children}
+ {tag} +
+); + +export const Gallery: React.FC = () => ( + +); diff --git a/apps/docs/src/sections/Hero.tsx b/apps/docs/src/sections/Hero.tsx new file mode 100644 index 0000000..e6286a0 --- /dev/null +++ b/apps/docs/src/sections/Hero.tsx @@ -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 ( +
+
+

+ Knobs that feel like hardware. +

+

+ dreamknob 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. +

+
+ pnpm add dreamknob + +
+ +
+
+ +
+
+ + + + + + + + + +
+
+ drag to rotate · scroll to nudge · for fine control · double-click to + reset · arrow keys work too +
+
+
+
+ ); +}; diff --git a/apps/docs/src/sections/Playground.tsx b/apps/docs/src/sections/Playground.tsx new file mode 100644 index 0000000..5bda4fa --- /dev/null +++ b/apps/docs/src/sections/Playground.tsx @@ -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 = { + 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(DEFAULTS); + const [value, setValue] = React.useState(50); + const [endValue, setEndValue] = React.useState(null); + const set = (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 ; + case 'metal': + return ; + case 'rubber': + return ; + case 'vintage': + return ; + case 'led': + return ; + case 'neon': + return ; + case 'stepped': + return ; + case 'fader': + return ; + case 'ledfader': + return ; + } + })(); + + return ( +
+
+
Playground
+

Turn it up. Then copy the code.

+

+ Every prop is live. The generated JSX below always matches what you're touching. +

+ +
+
+
+ + +
+
+ + set('size', +e.target.value)} + /> +
+
+
+ + set('min', num(e.target.value, 0))} /> +
+
+ + set('max', num(e.target.value, 100))} /> +
+
+
+
+ + set('step', num(e.target.value, 0))} + /> +
+
+ + set('angleRange', num(e.target.value, 270))} + /> +
+
+
+ + +
+
+ + + {logInvalid && ( + log taper needs min > 0 — using linear + )} +
+
+ + set('color', e.target.value)} /> +
+
+
+ + set('label', e.target.value)} /> +
+
+ + set('unit', e.target.value)} /> +
+
+
+ +
+
+ +
+
{preview}
+
+ onChange → {value} + onChangeEnd → {endValue ?? '—'} +
+ +
+
+
+
+ ); +}; diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css new file mode 100644 index 0000000..4fba32e --- /dev/null +++ b/apps/docs/src/styles.css @@ -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); +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 0000000..46f018f --- /dev/null +++ b/apps/docs/tsconfig.json @@ -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"] +} diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts new file mode 100644 index 0000000..c6a1b1f --- /dev/null +++ b/apps/docs/vite.config.ts @@ -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)), + }, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..5499733 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "dreamknob-monorepo", + "private": true, + "version": "0.1.0", + "scripts": { + "build": "pnpm --filter dreamknob build && pnpm --filter docs build", + "dev": "pnpm --filter docs dev", + "test": "pnpm --filter dreamknob test", + "typecheck": "pnpm -r typecheck" + }, + "packageManager": "pnpm@9.11.0" +} diff --git a/packages/dreamknob/README.md b/packages/dreamknob/README.md new file mode 100644 index 0000000..aeae998 --- /dev/null +++ b/packages/dreamknob/README.md @@ -0,0 +1,110 @@ +# dreamknob + +Studio-grade React knobs & faders. Rotary, linear, 2D, 3D, digital and analog — with a +headless core, any number range, any decimal precision, and colors you fully control. +Zero runtime dependencies. + +``` +npm install dreamknob +``` + +## Quick start + +```tsx +import { FlatKnob } from 'dreamknob' + +function Volume() { + const [value, setValue] = useState(64) + return ( + + ) +} +``` + +Uncontrolled works too: pass `defaultValue` instead of `value`/`onChange` +(it also becomes the double-click reset target). + +## Interaction model + +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 | +| `↑ → / ↓ ←` | ± one step (`Shift` = 10×) | +| `PageUp/PageDown` | ± 10 % of range | +| `Home/End` | min / max | +| Double-click | reset to `defaultValue` | +| Touch / pen | pointer capture, `touch-action: none` | + +All knobs render `role="slider"` with `aria-valuemin/max/now/text`. + +## Value handling + +- `min` / `max` — any numbers (negative, fractional, huge). +- `step` — decimal snap increments (`0.5`, `0.001`, `1e-7`…) with float-drift-safe + rounding; display precision inferred automatically, overridable via `decimals`. +- `values={[0.25, 0.5, 1, 2, 4]}` — discrete allowed values. +- `steps={5}` — evenly spaced detents (selector knobs). +- `taper` — `linearTaper` (default), `logTaper` (frequencies), `powTaper(n)` (gain), + or your own `{ toNormalized, fromNormalized }`. +- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks. + +## Prebuilt skins + +```tsx +import { + FlatKnob, // clean 2D arc knob (arcFrom="center" for pan knobs) + MetalKnob, // 3D brushed aluminum, knurled rim (tone="silver" | "dark") + RubberKnob, // 3D soft-touch synth knob with glow halo + VintageKnob, // chicken-head bakelite over a printed scale + LEDKnob, // segmented LED ring + true seven-segment readout + NeonKnob, // glowing arc for dark UIs + SteppedKnob, // detented selector (positions={['LP','BP','HP']}) + Fader, // linear channel fader, vertical or horizontal + LEDFader, // segmented LED meter-fader with color zones + SegmentDisplay, // standalone seven-segment numeric display +} from 'dreamknob' +``` + +Every skin takes the core props plus `size`, `label`, `showValue`, `unit`, `format`, +and per-part color props (`color`, `trackColor`, `faceColor`, `bodyColor`, …). + +## Compose your own + +```tsx +import { Knob, Arc, Pointer, Ticks, KnobValue } from 'dreamknob' + + + + + + + +``` + +`` also accepts a render function: `{(ctx) => }`. + +## Fully headless + +```tsx +import { useKnob } from 'dreamknob' + +const { value, normalized, angle, isDragging, bind, setValue } = useKnob({ + min: 0, max: 11, step: 0.1, defaultValue: 11, 'aria-label': 'Volume', +}) +return
…your own rendering…
+``` + +MIT diff --git a/packages/dreamknob/package.json b/packages/dreamknob/package.json new file mode 100644 index 0000000..f487b2d --- /dev/null +++ b/packages/dreamknob/package.json @@ -0,0 +1,51 @@ +{ + "name": "dreamknob", + "version": "0.1.0", + "description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.", + "license": "MIT", + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean", + "dev": "tsup src/index.ts --format esm --dts --watch", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "react", + "knob", + "fader", + "slider", + "rotary", + "audio", + "synth", + "dial", + "typescript" + ], + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tsup": "^8.1.0", + "typescript": "^5.5.3", + "vitest": "^2.0.3" + } +} diff --git a/packages/dreamknob/src/components/Knob.tsx b/packages/dreamknob/src/components/Knob.tsx new file mode 100644 index 0000000..51facf5 --- /dev/null +++ b/packages/dreamknob/src/components/Knob.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { KnobContextProvider, type KnobRenderContext } from '../core/context'; +import type { KnobCoreProps } from '../core/types'; +import { useKnob } from '../hooks/useKnob'; + +export interface KnobProps extends KnobCoreProps { + /** Square canvas size in px. Default: 80. */ + size?: number; + className?: string; + style?: React.CSSProperties; + /** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */ + children?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode); + /** Extra content rendered inside the wrapper but outside the SVG (e.g. HTML labels). */ + overlay?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode); +} + +/** + * Headless-but-visual knob container: wires up the full interaction model + * (rotary/relative/track drag, wheel, keyboard, double-click reset, a11y) + * and provides a render context for composable SVG primitives. + */ +export const Knob: React.FC = ({ + size = 80, + className, + style, + children, + overlay, + ...core +}) => { + const knob = useKnob(core); + const ctx: KnobRenderContext = { + value: knob.value, + normalized: knob.normalized, + angle: knob.angle, + isDragging: knob.isDragging, + min: knob.min, + max: knob.max, + decimals: knob.decimals, + angleOffset: knob.angleOffset, + angleRange: knob.angleRange, + size, + center: size / 2, + }; + + return ( + +
+ + {typeof overlay === 'function' ? overlay(ctx) : overlay} +
+
+ ); +}; diff --git a/packages/dreamknob/src/core/context.ts b/packages/dreamknob/src/core/context.ts new file mode 100644 index 0000000..744dead --- /dev/null +++ b/packages/dreamknob/src/core/context.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; +import type { KnobState } from './types'; + +export interface KnobRenderContext extends KnobState { + /** Square canvas size in px. */ + size: number; + /** Center coordinate (size / 2). */ + center: number; +} + +const Ctx = React.createContext(null); + +export const KnobContextProvider = Ctx.Provider; + +export function useKnobContext(): KnobRenderContext { + const ctx = React.useContext(Ctx); + if (!ctx) { + throw new Error( + 'dreamknob: this component must be rendered inside a (or a prebuilt knob).', + ); + } + return ctx; +} diff --git a/packages/dreamknob/src/core/math.test.ts b/packages/dreamknob/src/core/math.test.ts new file mode 100644 index 0000000..1adf2e0 --- /dev/null +++ b/packages/dreamknob/src/core/math.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + angleFromNormalized, + angleFromPoint, + clamp, + decimalsFromStep, + describeArc, + findClosest, + linearTaper, + logTaper, + normalizedFromAngle, + powTaper, + roundTo, + snapToStep, +} from './math'; + +describe('clamp', () => { + it('clamps into range', () => { + expect(clamp(5, 0, 10)).toBe(5); + expect(clamp(-1, 0, 10)).toBe(0); + expect(clamp(11, 0, 10)).toBe(10); + }); +}); + +describe('decimalsFromStep', () => { + it('infers decimal places', () => { + expect(decimalsFromStep(1)).toBe(0); + expect(decimalsFromStep(0.5)).toBe(1); + expect(decimalsFromStep(0.25)).toBe(2); + expect(decimalsFromStep(0.001)).toBe(3); + expect(decimalsFromStep(1e-7)).toBe(7); + }); + it('falls back for continuous knobs', () => { + expect(decimalsFromStep(0)).toBe(3); + expect(decimalsFromStep(NaN)).toBe(3); + }); +}); + +describe('roundTo', () => { + it('avoids float drift', () => { + expect(roundTo(0.1 + 0.2, 2)).toBe(0.3); + expect(roundTo(1.005, 2)).toBe(1.01); + expect(roundTo(123.456789, 4)).toBe(123.4568); + expect(roundTo(5, 0)).toBe(5); + }); +}); + +describe('snapToStep', () => { + it('snaps anchored at min', () => { + expect(snapToStep(7.3, 0.5, 0)).toBe(7.5); + expect(snapToStep(7.2, 0.5, 0)).toBe(7.0); + expect(roundTo(snapToStep(0.07, 0.02, 0.01), 2)).toBe(0.07); + }); + it('ignores non-positive steps', () => { + expect(snapToStep(7.3, 0, 0)).toBe(7.3); + }); +}); + +describe('findClosest', () => { + it('finds the nearest entry regardless of order', () => { + expect(findClosest([1, 100, 5, 50], 42)).toBe(50); + expect(findClosest([0.25, 0.5, 1, 2, 4], 0.8)).toBe(1); + }); +}); + +describe('tapers', () => { + it('linear round-trips', () => { + expect(linearTaper.fromNormalized(0.5, 0, 100)).toBe(50); + expect(linearTaper.toNormalized(50, 0, 100)).toBe(0.5); + }); + it('log taper maps geometric midpoints', () => { + expect(roundTo(logTaper.fromNormalized(0.5, 20, 20000), 3)).toBe(632.456); + expect(roundTo(logTaper.toNormalized(2000, 20, 20000), 4)).toBe( + roundTo(Math.log(100) / Math.log(1000), 4), + ); + }); + it('pow taper round-trips', () => { + const t = powTaper(2); + const n = t.toNormalized(t.fromNormalized(0.3, 0, 10), 0, 10); + expect(roundTo(n, 10)).toBe(0.3); + }); +}); + +describe('angles', () => { + it('maps pointer position to clockwise angle from 12 o\'clock', () => { + expect(angleFromPoint(50, 0, 50, 50)).toBe(0); // above center + expect(angleFromPoint(100, 50, 50, 50)).toBe(90); // right + expect(angleFromPoint(50, 100, 50, 50)).toBe(180); // below + expect(angleFromPoint(0, 50, 50, 50)).toBe(270); // left + }); + + it('maps angle to normalized travel (225°/270° audio knob)', () => { + expect(normalizedFromAngle(225, 225, 270)).toBe(0); + expect(normalizedFromAngle(0, 225, 270)).toBe(0.5); // 12 o'clock is mid travel + expect(normalizedFromAngle(135, 225, 270)).toBe(1); + }); + + it('snaps the dead zone to the nearest end (rc-knob feel)', () => { + // Dead zone spans 135..225; below its midpoint sticks to max. + expect(normalizedFromAngle(150, 225, 270)).toBe(1); + expect(normalizedFromAngle(210, 225, 270)).toBe(0); + }); + + it('round-trips normalized to angle', () => { + expect(angleFromNormalized(0.5, 225, 270)).toBe(360); + expect(angleFromNormalized(0, 225, 270)).toBe(225); + }); +}); + +describe('describeArc', () => { + it('produces a drawable path', () => { + const d = describeArc(50, 50, 40, 225, 495); + expect(d.startsWith('M ')).toBe(true); + expect(d).toContain('A 40 40'); + }); + it('returns empty for zero sweep', () => { + expect(describeArc(50, 50, 40, 100, 100)).toBe(''); + }); + it('caps at just under a full circle', () => { + expect(describeArc(50, 50, 40, 0, 720)).not.toBe(''); + }); +}); diff --git a/packages/dreamknob/src/core/math.ts b/packages/dreamknob/src/core/math.ts new file mode 100644 index 0000000..80bf49a --- /dev/null +++ b/packages/dreamknob/src/core/math.ts @@ -0,0 +1,156 @@ +/** Clamp `value` into the inclusive range [min, max]. */ +export const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +/** + * Infer the number of decimal places implied by a step size, + * e.g. 0.25 -> 2, 1 -> 0, 1e-5 -> 5. + */ +export const decimalsFromStep = (step: number): number => { + if (!Number.isFinite(step) || step <= 0) return 3; + const s = step.toString(); + const exp = s.indexOf('e-'); + if (exp !== -1) return parseInt(s.slice(exp + 2), 10); + const dot = s.indexOf('.'); + return dot === -1 ? 0 : s.length - dot - 1; +}; + +/** Round to a fixed number of decimal places without float drift (0.1 + 0.2 style). */ +export const roundTo = (value: number, decimals: number): number => { + const d = clamp(Math.trunc(decimals), 0, 15); + return Number(`${Math.round(Number(`${value}e${d}`))}e-${d}`); +}; + +/** Snap `value` to the nearest multiple of `step`, anchored at `min`. */ +export const snapToStep = (value: number, step: number, min: number): number => { + if (!Number.isFinite(step) || step <= 0) return value; + return min + Math.round((value - min) / step) * step; +}; + +/** Find the entry of `values` closest to `value`. */ +export const findClosest = (values: readonly number[], value: number): number => { + let best = values[0]; + let bestDelta = Infinity; + for (const v of values) { + const delta = Math.abs(v - value); + if (delta < bestDelta) { + best = v; + bestDelta = delta; + } + } + return best; +}; + +// --------------------------------------------------------------------------- +// Tapers — map between value space and the normalized [0, 1] travel of the +// control. Audio parameters are often logarithmic (frequency) or power-law +// (gain), so the travel-to-value curve is pluggable. +// --------------------------------------------------------------------------- + +export interface Taper { + /** value in [min, max] -> normalized position in [0, 1] */ + toNormalized(value: number, min: number, max: number): number; + /** normalized position in [0, 1] -> value in [min, max] */ + fromNormalized(n: number, min: number, max: number): number; +} + +export const linearTaper: Taper = { + toNormalized: (value, min, max) => (max === min ? 0 : (value - min) / (max - min)), + fromNormalized: (n, min, max) => min + (max - min) * n, +}; + +/** + * Logarithmic taper (equal ratios per travel). Requires min and max to be + * non-zero and share a sign — ideal for frequency ranges like 20..20000 Hz. + */ +export const logTaper: Taper = { + toNormalized: (value, min, max) => { + if (min === max) return 0; + return Math.log(value / min) / Math.log(max / min); + }, + fromNormalized: (n, min, max) => min * Math.pow(max / min, n), +}; + +/** + * Power-law taper. `exponent > 1` gives finer resolution near min (good for + * gain), `exponent < 1` near max. + */ +export const powTaper = (exponent: number): Taper => ({ + toNormalized: (value, min, max) => + max === min ? 0 : Math.pow((value - min) / (max - min), 1 / exponent), + fromNormalized: (n, min, max) => min + (max - min) * Math.pow(n, exponent), +}); + +// --------------------------------------------------------------------------- +// Angles. Convention: degrees measured CLOCKWISE from 12 o'clock. +// A typical audio knob starts at 225° (7:30) and sweeps 270° to 135° (4:30). +// --------------------------------------------------------------------------- + +const DEG = Math.PI / 180; + +export const polarToCartesian = ( + cx: number, + cy: number, + radius: number, + angleDeg: number, +): { x: number; y: number } => ({ + x: cx + radius * Math.sin(angleDeg * DEG), + y: cy - radius * Math.cos(angleDeg * DEG), +}); + +/** + * SVG path for a clockwise arc from `startAngle` to `endAngle` + * (degrees clockwise from 12 o'clock). Sweeps of >= 360° are capped just + * short of a full circle so the path stays drawable. + */ +export const describeArc = ( + cx: number, + cy: number, + radius: number, + startAngle: number, + endAngle: number, +): string => { + const sweep = Math.min(endAngle - startAngle, 359.999); + if (sweep <= 0) return ''; + const start = polarToCartesian(cx, cy, radius, startAngle); + const end = polarToCartesian(cx, cy, radius, startAngle + sweep); + const largeArc = sweep > 180 ? 1 : 0; + return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`; +}; + +/** + * Angle of the pointer position relative to a center, in degrees clockwise + * from 12 o'clock, normalized to [0, 360). + */ +export const angleFromPoint = ( + px: number, + py: number, + cx: number, + cy: number, +): number => { + const deg = Math.atan2(px - cx, cy - py) / DEG; + return (deg + 360) % 360; +}; + +/** + * Map an absolute pointer angle to a normalized position given the knob's + * travel (angleOffset..angleOffset+angleRange). Angles inside the dead zone + * snap to the nearest end — this is the classic rc-knob grab-and-rotate feel. + */ +export const normalizedFromAngle = ( + angle: number, + angleOffset: number, + angleRange: number, +): number => { + const rel = (((angle - angleOffset) % 360) + 360) % 360; + if (rel <= angleRange) return clamp(rel / angleRange, 0, 1); + // Dead zone: snap to whichever end of the travel is angularly closer. + return rel - angleRange < (360 - angleRange) / 2 ? 1 : 0; +}; + +/** Normalized position -> absolute display angle (deg clockwise from 12 o'clock). */ +export const angleFromNormalized = ( + n: number, + angleOffset: number, + angleRange: number, +): number => angleOffset + n * angleRange; diff --git a/packages/dreamknob/src/core/types.ts b/packages/dreamknob/src/core/types.ts new file mode 100644 index 0000000..b0df137 --- /dev/null +++ b/packages/dreamknob/src/core/types.ts @@ -0,0 +1,82 @@ +import type { Taper } from './math'; + +/** How pointer dragging maps to value changes. */ +export type InteractionMode = + /** Track the pointer's absolute angle around the knob center (grab-and-rotate, rc-knob feel). */ + | 'rotary' + /** Relative vertical drag: up increases (classic DAW plugin feel). */ + | 'vertical' + /** Relative horizontal drag: right increases. */ + | 'horizontal' + /** Combined vertical + horizontal relative drag. */ + | 'both' + /** Absolute position along the element's main axis (fader/slider feel). */ + | 'track-vertical' + | 'track-horizontal'; + +export interface KnobCoreProps { + /** Controlled value. Provide together with `onChange` for controlled mode. */ + value?: number; + /** Initial value for uncontrolled mode; also the double-click reset target. */ + defaultValue?: number; + min?: number; + max?: number; + /** + * Snap emitted values to multiples of `step` (anchored at `min`). + * May be fractional, e.g. 0.01. Omit (or 0) for continuous values. + */ + step?: number; + /** + * Number of decimal places for emitted values. Defaults to the precision + * implied by `step`, or 3 for continuous knobs. + */ + decimals?: number; + /** Restrict values to a discrete list (e.g. [0.25, 0.5, 1, 2, 4]). Overrides `step`. */ + values?: readonly number[]; + /** Number of evenly spaced detents across the travel. Overrides `step` snapping in normalized space. */ + steps?: number; + /** Travel-to-value curve. Defaults to linear. Use `logTaper` / `powTaper(n)` for audio params. */ + taper?: Taper; + /** Drag behaviour. Default: 'rotary'. */ + interaction?: InteractionMode; + /** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */ + dragSensitivity?: number; + /** Multiplier applied while Shift is held during relative drags / wheel. Default: 0.1. */ + fineMultiplier?: number; + /** Angle (deg clockwise from 12 o'clock) where travel begins. Default: 225. */ + angleOffset?: number; + /** Total sweep in degrees. Default: 270. */ + angleRange?: number; + /** Enable mouse-wheel adjustment. Default: true. */ + enableWheel?: boolean; + /** Reset to `defaultValue` on double click. Default: true when `defaultValue` is set. */ + doubleClickReset?: boolean; + disabled?: boolean; + readOnly?: boolean; + /** Fired with each (rounded, snapped) value change. */ + onChange?: (value: number) => void; + /** Fired when an adjustment gesture ends (pointer up, wheel settle, key release). */ + onChangeEnd?: (value: number) => void; + /** Fired when a drag gesture starts. */ + onChangeStart?: (value: number) => void; + /** Accessible name for the slider role. */ + 'aria-label'?: string; + 'aria-labelledby'?: string; + /** Custom text for screen readers, e.g. `v => `${v} dB``. */ + getAriaValueText?: (value: number) => string; +} + +export interface KnobState { + /** Current (snapped, rounded) value. */ + value: number; + /** Normalized travel position in [0, 1] (taper space). */ + normalized: number; + /** Display angle in degrees clockwise from 12 o'clock. */ + angle: number; + isDragging: boolean; + min: number; + max: number; + decimals: number; + angleOffset: number; + angleRange: number; +} diff --git a/packages/dreamknob/src/digital/SegmentDisplay.tsx b/packages/dreamknob/src/digital/SegmentDisplay.tsx new file mode 100644 index 0000000..bd8c3eb --- /dev/null +++ b/packages/dreamknob/src/digital/SegmentDisplay.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { formatForDisplay, renderSegmentText } from './segments'; + +export interface SegmentDisplayProps { + value: number | string; + /** Number of digit cells (decimal points don't count). Default: 4. */ + digits?: number; + decimals?: number; + /** Digit height in px. Default: 28. */ + height?: number; + color?: string; + /** Panel background. Set to 'none' to disable. */ + background?: string; + /** Opacity of unlit segments. Default: 0.09. */ + ghostOpacity?: number; + /** Italic skew in degrees. Default: 6. */ + skew?: number; + /** LED glow strength (0 disables). Default: 1.6. */ + glow?: number; + padding?: number; + className?: string; + style?: React.CSSProperties; +} + +/** + * A standalone seven-segment LED/LCD numeric display — for readouts, meters + * and digital panels. + */ +export const SegmentDisplay: React.FC = ({ + value, + digits = 4, + decimals = 0, + height = 28, + color = '#3df2ad', + background = '#0a0d0c', + ghostOpacity = 0.09, + skew = 6, + glow = 1.6, + padding = 6, + className, + style, +}) => { + const filterId = React.useId(); + const text = + typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value; + const { nodes, width, height: cellH } = renderSegmentText(text, { + color, + ghostOpacity, + skew, + }); + + const scale = height / cellH; + const w = width * scale + padding * 2 + height * 0.15; // skew headroom + const h = height + padding * 2; + + return ( + + {glow > 0 && ( + + + + + + + + + + )} + {background !== 'none' && ( + + )} + 0 ? `url(#${filterId})` : undefined} + > + {nodes} + + + ); +}; diff --git a/packages/dreamknob/src/digital/segments.tsx b/packages/dreamknob/src/digital/segments.tsx new file mode 100644 index 0000000..1b26a57 --- /dev/null +++ b/packages/dreamknob/src/digital/segments.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; + +// Seven-segment geometry in a local 10 x 18 digit cell. +// Segments: a top, b top-right, c bottom-right, d bottom, e bottom-left, +// f top-left, g middle. + +const DIGIT_W = 10; +const DIGIT_H = 18; +const GAP = 3.2; // spacing between digit cells (leaves room for decimal points) + +type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g'; + +const CHAR_SEGMENTS: Record = { + '0': ['a', 'b', 'c', 'd', 'e', 'f'], + '1': ['b', 'c'], + '2': ['a', 'b', 'g', 'e', 'd'], + '3': ['a', 'b', 'g', 'c', 'd'], + '4': ['f', 'g', 'b', 'c'], + '5': ['a', 'f', 'g', 'c', 'd'], + '6': ['a', 'f', 'g', 'e', 'c', 'd'], + '7': ['a', 'b', 'c'], + '8': ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + '9': ['a', 'b', 'c', 'd', 'f', 'g'], + '-': ['g'], + ' ': [], +}; + +const H_HALF = 1.1; // half thickness +const INSET = 0.45; // gap between adjacent segments + +const hSegment = (y: number): string => { + const x1 = 1 + INSET; + const x2 = DIGIT_W - 1 - INSET; + return `${x1},${y} ${x1 + H_HALF},${y - H_HALF} ${x2 - H_HALF},${y - H_HALF} ${x2},${y} ${x2 - H_HALF},${y + H_HALF} ${x1 + H_HALF},${y + H_HALF}`; +}; + +const vSegment = (x: number, y1: number, y2: number): string => { + const a = y1 + INSET; + const b = y2 - INSET; + return `${x},${a} ${x + H_HALF},${a + H_HALF} ${x + H_HALF},${b - H_HALF} ${x},${b} ${x - H_HALF},${b - H_HALF} ${x - H_HALF},${a + H_HALF}`; +}; + +const SEGMENT_POINTS: Record = { + a: hSegment(1), + g: hSegment(DIGIT_H / 2), + d: hSegment(DIGIT_H - 1), + f: vSegment(1, 1, DIGIT_H / 2), + b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2), + e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1), + c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1), +}; + +export interface SegmentRenderOptions { + color: string; + /** Opacity of unlit "ghost" segments. 0 disables them. */ + ghostOpacity: number; + /** Negative skew for the classic italic LCD look, in degrees. */ + skew: number; +} + +/** + * Render `text` (digits, '-', '.', ' ') as seven-segment polygons in local + * coordinates (digit height 18). Returns the nodes plus the total width. + */ +export const renderSegmentText = ( + text: string, + { color, ghostOpacity, skew }: SegmentRenderOptions, +): { nodes: React.ReactNode; width: number; height: number } => { + const cells: React.ReactNode[] = []; + let x = 0; + let key = 0; + + for (const ch of text) { + if (ch === '.') { + // Decimal point sits in the gap after the previous digit. + cells.push( + , + ); + continue; + } + const lit = new Set(CHAR_SEGMENTS[ch] ?? []); + const digit: React.ReactNode[] = []; + (Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => { + const on = lit.has(seg); + if (!on && ghostOpacity <= 0) return; + digit.push( + , + ); + }); + cells.push( + + {digit} + , + ); + x += DIGIT_W + GAP; + } + + const width = Math.max(x - GAP, 0); + const nodes = skew ? ( + {cells} + ) : ( + <>{cells} + ); + return { nodes, width, height: DIGIT_H }; +}; + +/** Format a number for a fixed-width display, e.g. (3.5, 2, 1) -> " 3.5". */ +export const formatForDisplay = ( + value: number, + digits: number, + decimals: number, +): string => { + let text = value.toFixed(decimals); + const cellCount = (s: string) => s.replace(/\./g, '').length; + if (cellCount(text) > digits) text = ''.padStart(digits, '8'); // overflow + return text.padStart(digits + (text.includes('.') ? 1 : 0), ' '); +}; diff --git a/packages/dreamknob/src/hooks/useKnob.ts b/packages/dreamknob/src/hooks/useKnob.ts new file mode 100644 index 0000000..30c01a1 --- /dev/null +++ b/packages/dreamknob/src/hooks/useKnob.ts @@ -0,0 +1,350 @@ +import * as React from 'react'; +import { + angleFromNormalized, + angleFromPoint, + clamp, + decimalsFromStep, + findClosest, + linearTaper, + normalizedFromAngle, + roundTo, + snapToStep, +} from '../core/math'; +import type { KnobCoreProps, KnobState } from '../core/types'; + +export interface UseKnobResult extends KnobState { + /** Ref for the interactive element. Required for wheel + rotary geometry. */ + ref: React.RefObject; + /** Spread onto the interactive element. */ + bind: { + ref: React.RefObject; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onPointerCancel: (e: React.PointerEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onKeyUp: (e: React.KeyboardEvent) => void; + onDoubleClick: (e: React.MouseEvent) => void; + role: 'slider'; + tabIndex: number; + 'aria-valuemin': number; + 'aria-valuemax': number; + 'aria-valuenow': number; + 'aria-valuetext'?: string; + 'aria-label'?: string; + 'aria-labelledby'?: string; + 'aria-disabled'?: boolean; + 'aria-readonly'?: boolean; + style: React.CSSProperties; + }; + /** Imperatively set the value (snapped + clamped). */ + setValue: (value: number) => void; +} + +interface DragSession { + pointerId: number; + centerX: number; + centerY: number; + rect: DOMRect; + lastX: number; + lastY: number; + /** Continuous normalized position, kept un-snapped for smooth relative drags. */ + n: number; +} + +export function useKnob(props: KnobCoreProps): UseKnobResult { + const { + min = 0, + max = 100, + step = 0, + values, + steps, + taper = linearTaper, + interaction = 'rotary', + dragSensitivity = 200, + fineMultiplier = 0.1, + angleOffset = 225, + angleRange = 270, + enableWheel = true, + disabled = false, + readOnly = false, + onChange, + onChangeEnd, + onChangeStart, + getAriaValueText, + } = props; + + const decimals = props.decimals ?? (step > 0 ? decimalsFromStep(step) : 3); + const doubleClickReset = + props.doubleClickReset ?? props.defaultValue !== undefined; + const interactive = !disabled && !readOnly; + + /** Clamp, snap and round a raw value into an emittable one. */ + const constrain = React.useCallback( + (raw: number): number => { + let v = clamp(raw, Math.min(min, max), Math.max(min, max)); + if (values && values.length > 0) { + v = findClosest(values, v); + } else if (steps && steps > 1) { + const n = taper.toNormalized(v, min, max); + const snapped = Math.round(n * (steps - 1)) / (steps - 1); + v = taper.fromNormalized(snapped, min, max); + } else if (step > 0) { + v = clamp(snapToStep(v, step, min), Math.min(min, max), Math.max(min, max)); + } + return roundTo(v, decimals); + }, + [min, max, step, values, steps, taper, decimals], + ); + + const isControlled = props.value !== undefined; + const [internalValue, setInternalValue] = React.useState(() => + constrain(props.defaultValue ?? min), + ); + const value = constrain(isControlled ? (props.value as number) : internalValue); + const [isDragging, setIsDragging] = React.useState(false); + + const valueRef = React.useRef(value); + valueRef.current = value; + + const emit = React.useCallback( + (raw: number) => { + const next = constrain(raw); + if (next === valueRef.current) return; + valueRef.current = next; + if (!isControlled) setInternalValue(next); + onChange?.(next); + }, + [constrain, isControlled, onChange], + ); + + const setFromNormalized = React.useCallback( + (n: number) => emit(taper.fromNormalized(clamp(n, 0, 1), min, max)), + [emit, taper, min, max], + ); + + // ------------------------------------------------------------------------- + // Pointer dragging + // ------------------------------------------------------------------------- + const ref = React.useRef(null); + const session = React.useRef(null); + + const applyPointer = React.useCallback( + (clientX: number, clientY: number, fine: boolean) => { + const s = session.current; + if (!s) return; + switch (interaction) { + case 'rotary': { + const angle = angleFromPoint(clientX, clientY, s.centerX, s.centerY); + setFromNormalized(normalizedFromAngle(angle, angleOffset % 360, angleRange)); + break; + } + case 'track-vertical': { + setFromNormalized((s.rect.bottom - clientY) / s.rect.height); + break; + } + case 'track-horizontal': { + setFromNormalized((clientX - s.rect.left) / s.rect.width); + break; + } + default: { + const scale = (fine ? fineMultiplier : 1) / dragSensitivity; + let dn = 0; + if (interaction === 'vertical' || interaction === 'both') + dn += (s.lastY - clientY) * scale; + if (interaction === 'horizontal' || interaction === 'both') + dn += (clientX - s.lastX) * scale; + s.n = clamp(s.n + dn, 0, 1); + setFromNormalized(s.n); + } + } + s.lastX = clientX; + s.lastY = clientY; + }, + [interaction, angleOffset, angleRange, dragSensitivity, fineMultiplier, setFromNormalized], + ); + + const onPointerDown = React.useCallback( + (e: React.PointerEvent) => { + if (!interactive || !ref.current) return; + if (e.pointerType === 'mouse' && e.button !== 0) return; + e.preventDefault(); + ref.current.focus({ preventScroll: true }); + ref.current.setPointerCapture(e.pointerId); + const rect = ref.current.getBoundingClientRect(); + session.current = { + pointerId: e.pointerId, + rect, + centerX: rect.left + rect.width / 2, + centerY: rect.top + rect.height / 2, + lastX: e.clientX, + lastY: e.clientY, + n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1), + }; + setIsDragging(true); + onChangeStart?.(valueRef.current); + // Fader-style modes jump to the pressed position immediately. + if (interaction === 'track-vertical' || interaction === 'track-horizontal') { + applyPointer(e.clientX, e.clientY, e.shiftKey); + } + }, + [interactive, interaction, taper, min, max, onChangeStart, applyPointer], + ); + + const onPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (!session.current || e.pointerId !== session.current.pointerId) return; + applyPointer(e.clientX, e.clientY, e.shiftKey); + }, + [applyPointer], + ); + + const endDrag = React.useCallback( + (e: React.PointerEvent) => { + if (!session.current || e.pointerId !== session.current.pointerId) return; + session.current = null; + setIsDragging(false); + onChangeEnd?.(valueRef.current); + }, + [onChangeEnd], + ); + + // ------------------------------------------------------------------------- + // Wheel — needs a non-passive listener so preventDefault stops page scroll. + // ------------------------------------------------------------------------- + const wheelSettle = React.useRef>(); + const nudge = React.useCallback( + (direction: number, multiplier = 1) => { + const current = valueRef.current; + if (values && values.length > 0) { + const sorted = [...values].sort((a, b) => a - b); + const idx = sorted.indexOf(findClosest(sorted, current)); + emit(sorted[clamp(idx + direction, 0, sorted.length - 1)]); + } else if (steps && steps > 1) { + const n = taper.toNormalized(current, min, max); + setFromNormalized(n + direction / (steps - 1)); + } else { + const base = step > 0 ? step : (max - min) / 100; + emit(current + direction * base * multiplier); + } + }, + [values, steps, step, min, max, taper, emit, setFromNormalized], + ); + const nudgeRef = React.useRef(nudge); + nudgeRef.current = nudge; + + React.useEffect(() => { + const el = ref.current; + if (!el || !enableWheel || !interactive) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const direction = e.deltaY < 0 || e.deltaX > 0 ? 1 : -1; + nudgeRef.current(direction, e.shiftKey ? fineMultiplier : 1); + clearTimeout(wheelSettle.current); + wheelSettle.current = setTimeout(() => onChangeEnd?.(valueRef.current), 250); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => { + el.removeEventListener('wheel', onWheel); + clearTimeout(wheelSettle.current); + }; + }, [enableWheel, interactive, fineMultiplier, onChangeEnd]); + + // ------------------------------------------------------------------------- + // Keyboard + // ------------------------------------------------------------------------- + const keyAdjusted = React.useRef(false); + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (!interactive) return; + const span = max - min; + let handled = true; + switch (e.key) { + case 'ArrowUp': + case 'ArrowRight': + nudge(1, e.shiftKey ? 10 : 1); + break; + case 'ArrowDown': + case 'ArrowLeft': + nudge(-1, e.shiftKey ? 10 : 1); + break; + case 'PageUp': + emit(valueRef.current + span / 10); + break; + case 'PageDown': + emit(valueRef.current - span / 10); + break; + case 'Home': + emit(min); + break; + case 'End': + emit(max); + break; + default: + handled = false; + } + if (handled) { + e.preventDefault(); + keyAdjusted.current = true; + } + }, + [interactive, nudge, emit, min, max], + ); + + const onKeyUp = React.useCallback(() => { + if (keyAdjusted.current) { + keyAdjusted.current = false; + onChangeEnd?.(valueRef.current); + } + }, [onChangeEnd]); + + const onDoubleClick = React.useCallback(() => { + if (!interactive || !doubleClickReset || props.defaultValue === undefined) return; + emit(props.defaultValue); + onChangeEnd?.(valueRef.current); + }, [interactive, doubleClickReset, props.defaultValue, emit, onChangeEnd]); + + const normalized = clamp(taper.toNormalized(value, min, max), 0, 1); + const angle = angleFromNormalized(normalized, angleOffset, angleRange); + + return { + value, + normalized, + angle, + isDragging, + min, + max, + decimals, + angleOffset, + angleRange, + ref, + setValue: emit, + bind: { + ref, + onPointerDown, + onPointerMove, + onPointerUp: endDrag, + onPointerCancel: endDrag, + onKeyDown, + onKeyUp, + onDoubleClick, + role: 'slider', + tabIndex: disabled ? -1 : 0, + 'aria-valuemin': min, + 'aria-valuemax': max, + 'aria-valuenow': value, + 'aria-valuetext': getAriaValueText ? getAriaValueText(value) : undefined, + 'aria-label': props['aria-label'], + 'aria-labelledby': props['aria-labelledby'], + 'aria-disabled': disabled || undefined, + 'aria-readonly': readOnly || undefined, + style: { + touchAction: 'none', + userSelect: 'none', + WebkitUserSelect: 'none', + cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'grab', + outline: 'none', + }, + }, + }; +} diff --git a/packages/dreamknob/src/index.ts b/packages/dreamknob/src/index.ts new file mode 100644 index 0000000..bc05e2a --- /dev/null +++ b/packages/dreamknob/src/index.ts @@ -0,0 +1,44 @@ +// Core +export { useKnob, type UseKnobResult } from './hooks/useKnob'; +export { Knob, type KnobProps } from './components/Knob'; +export { useKnobContext, type KnobRenderContext } from './core/context'; +export type { KnobCoreProps, KnobState, InteractionMode } from './core/types'; +export { + clamp, + roundTo, + snapToStep, + decimalsFromStep, + findClosest, + linearTaper, + logTaper, + powTaper, + type Taper, + polarToCartesian, + describeArc, + angleFromPoint, + angleFromNormalized, + normalizedFromAngle, +} from './core/math'; + +// Composable primitives +export { Arc, type ArcProps } from './primitives/Arc'; +export { Pointer, type PointerProps } from './primitives/Pointer'; +export { Ticks, type TicksProps } from './primitives/Ticks'; +export { Face, type FaceProps } from './primitives/Face'; +export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from './primitives/Text'; + +// Digital +export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay'; + +// Prebuilt skins +export type { SkinProps } from './skins/shared'; +export { FlatKnob, type FlatKnobProps } from './skins/FlatKnob'; +export { MetalKnob, type MetalKnobProps } from './skins/MetalKnob'; +export { RubberKnob, type RubberKnobProps } from './skins/RubberKnob'; +export { VintageKnob, type VintageKnobProps } from './skins/VintageKnob'; +export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob'; +export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob'; +export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob'; +export { Fader, type FaderProps } from './skins/Fader'; +export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader'; +export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter'; diff --git a/packages/dreamknob/src/primitives/Arc.tsx b/packages/dreamknob/src/primitives/Arc.tsx new file mode 100644 index 0000000..9d49a32 --- /dev/null +++ b/packages/dreamknob/src/primitives/Arc.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { useKnobContext } from '../core/context'; +import { describeArc } from '../core/math'; + +export interface ArcProps { + /** Arc radius. Defaults to (size - thickness) / 2. */ + radius?: number; + thickness?: number; + /** Value arc color. */ + color?: string; + /** Background track color. Omit to skip the track. */ + trackColor?: string; + /** Draw the value arc from the minimum ('min') or from the travel center ('center', pan-style). */ + from?: 'min' | 'center'; + cap?: 'butt' | 'round'; + /** Extra SVG props for the value arc path (e.g. filter for glow). */ + arcProps?: React.SVGProps; + opacity?: number; +} + +/** Background track + value arc following the knob's travel. */ +export const Arc: React.FC = ({ + radius, + thickness = 4, + color = 'currentColor', + trackColor, + from = 'min', + cap = 'round', + arcProps, + opacity, +}) => { + const { size, center, normalized, angleOffset, angleRange } = useKnobContext(); + const r = radius ?? (size - thickness) / 2; + + let start: number; + let end: number; + if (from === 'center') { + const mid = angleOffset + angleRange / 2; + const now = angleOffset + normalized * angleRange; + start = Math.min(mid, now); + end = Math.max(mid, now); + } else { + start = angleOffset; + end = angleOffset + normalized * angleRange; + } + + return ( + + {trackColor && ( + + )} + {end - start > 0.0001 && ( + + )} + + ); +}; diff --git a/packages/dreamknob/src/primitives/Face.tsx b/packages/dreamknob/src/primitives/Face.tsx new file mode 100644 index 0000000..c236d92 --- /dev/null +++ b/packages/dreamknob/src/primitives/Face.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { useKnobContext } from '../core/context'; + +export interface FaceProps extends React.SVGProps { + /** Body radius. Defaults to size/2 - 6. */ + radius?: number; +} + +/** The knob body: a centered circle. Style via fill/stroke/filter props. */ +export const Face: React.FC = ({ radius, ...rest }) => { + const { size, center } = useKnobContext(); + return ; +}; diff --git a/packages/dreamknob/src/primitives/GlowFilter.tsx b/packages/dreamknob/src/primitives/GlowFilter.tsx new file mode 100644 index 0000000..f7a0388 --- /dev/null +++ b/packages/dreamknob/src/primitives/GlowFilter.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { useKnobContext } from '../core/context'; + +export interface GlowFilterProps { + id: string; + /** Gaussian blur radius in px. */ + blur: number; +} + +/** + * LED-style glow filter with a userSpaceOnUse region covering the whole + * canvas. The default objectBoundingBox region collapses for thin stroked + * paths (a straight line has a zero-area geometric bbox), which clips the + * stroke and glow — this avoids that entirely. + */ +export const GlowFilter: React.FC = ({ id, blur }) => { + const { size } = useKnobContext(); + const pad = size * 0.3; + return ( + + + + + + + + ); +}; diff --git a/packages/dreamknob/src/primitives/Pointer.tsx b/packages/dreamknob/src/primitives/Pointer.tsx new file mode 100644 index 0000000..36ee574 --- /dev/null +++ b/packages/dreamknob/src/primitives/Pointer.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { useKnobContext } from '../core/context'; + +export interface PointerProps { + /** Built-in shapes. Provide children instead for a custom pointer. */ + type?: 'line' | 'circle' | 'triangle'; + /** Distance from center to the pointer's outer end. Defaults to size/2 - 4. */ + radius?: number; + /** Length of the pointer along the radial axis. */ + length?: number; + width?: number; + color?: string; + cap?: 'butt' | 'round'; + /** Extra SVG props applied to the shape. */ + shapeProps?: React.SVGProps; + /** Custom pointer content, drawn pointing at 12 o'clock and rotated for you. */ + children?: React.ReactNode; +} + +/** Rotating indicator. Children (or the built-in shape) are authored pointing up. */ +export const Pointer: React.FC = ({ + type = 'line', + radius, + length = 12, + width = 4, + color = 'currentColor', + cap = 'round', + shapeProps, + children, +}) => { + const { size, center, angle } = useKnobContext(); + const r = radius ?? size / 2 - 4; + + let shape: React.ReactNode = children; + if (!shape) { + if (type === 'line') { + shape = ( + )} + /> + ); + } else if (type === 'circle') { + shape = ( + )} + /> + ); + } else { + const half = width / 2; + const tipY = center - r; + shape = ( + )} + /> + ); + } + } + + return {shape}; +}; diff --git a/packages/dreamknob/src/primitives/Text.tsx b/packages/dreamknob/src/primitives/Text.tsx new file mode 100644 index 0000000..cf1aa63 --- /dev/null +++ b/packages/dreamknob/src/primitives/Text.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { useKnobContext } from '../core/context'; + +/** "64.000" -> "64", "12.50" -> "12.5"; leaves integers untouched. */ +const trimZeros = (s: string): string => + s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s; + +export interface KnobValueProps { + /** Fixed decimal places for display. Default: the knob's `decimals`, with trailing zeros trimmed. */ + decimals?: number; + /** Full custom formatter — takes precedence over `decimals` / `unit`. */ + format?: (value: number) => string; + unit?: string; + fontSize?: number; + color?: string; + fontFamily?: string; + fontWeight?: number | string; + /** Vertical offset from center. */ + dy?: number; + textProps?: React.SVGProps; +} + +/** Numeric readout in the middle of the knob. */ +export const KnobValue: React.FC = ({ + decimals, + format, + unit, + fontSize, + color = 'currentColor', + fontFamily, + fontWeight = 600, + dy = 0, + textProps, +}) => { + const ctx = useKnobContext(); + const text = format + ? format(ctx.value) + : `${ + decimals !== undefined + ? ctx.value.toFixed(decimals) + : trimZeros(ctx.value.toFixed(ctx.decimals)) + }${unit ?? ''}`; + return ( + + {text} + + ); +}; + +export interface KnobLabelProps { + children: React.ReactNode; + fontSize?: number; + color?: string; + fontFamily?: string; + /** Vertical offset from center; defaults to just below the knob body. */ + dy?: number; + textProps?: React.SVGProps; +} + +/** Small caption, e.g. the parameter name. */ +export const KnobLabel: React.FC = ({ + children, + fontSize, + color = 'currentColor', + fontFamily, + dy, + textProps, +}) => { + const { size, center } = useKnobContext(); + return ( + + {children} + + ); +}; diff --git a/packages/dreamknob/src/primitives/Ticks.tsx b/packages/dreamknob/src/primitives/Ticks.tsx new file mode 100644 index 0000000..ceee406 --- /dev/null +++ b/packages/dreamknob/src/primitives/Ticks.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { useKnobContext } from '../core/context'; +import { polarToCartesian } from '../core/math'; + +export interface TicksProps { + count?: number; + /** Outer radius of the ticks. Defaults to size/2. */ + radius?: number; + length?: number; + width?: number; + color?: string; + /** Color for ticks at or below the current position. Enables "lit" scales. */ + activeColor?: string; + cap?: 'butt' | 'round'; + /** Extra props per tick line, by index. */ + getTickProps?: (index: number, active: boolean) => React.SVGProps | undefined; +} + +/** Radial tick marks distributed across the knob's travel. */ +export const Ticks: React.FC = ({ + count = 11, + radius, + length = 6, + width = 2, + color = 'currentColor', + activeColor, + cap = 'round', + getTickProps, +}) => { + const { size, center, normalized, angleOffset, angleRange } = useKnobContext(); + const outer = radius ?? size / 2; + const ticks: React.ReactNode[] = []; + + for (let i = 0; i < count; i++) { + const t = count === 1 ? 0 : i / (count - 1); + const angle = angleOffset + t * angleRange; + const p1 = polarToCartesian(center, center, outer, angle); + const p2 = polarToCartesian(center, center, outer - length, angle); + const active = t <= normalized + 1e-9; + ticks.push( + , + ); + } + return {ticks}; +}; diff --git a/packages/dreamknob/src/skins/Fader.tsx b/packages/dreamknob/src/skins/Fader.tsx new file mode 100644 index 0000000..69d3903 --- /dev/null +++ b/packages/dreamknob/src/skins/Fader.tsx @@ -0,0 +1,193 @@ +import * as React from 'react'; +import { useKnob } from '../hooks/useKnob'; +import type { KnobCoreProps } from '../core/types'; +import { MONO_FONT, UI_FONT } from './shared'; + +export interface FaderProps extends Omit { + orientation?: 'vertical' | 'horizontal'; + /** Travel length in px. Default: 160. */ + length?: number; + /** Cross-axis size in px. Default: 44. */ + breadth?: number; + color?: string; + trackColor?: string; + /** Fill the travelled part of the track. Default: true. */ + showFill?: boolean; + tickCount?: number; + tickColor?: string; + label?: string; + showValue?: boolean; + unit?: string; + format?: (value: number) => string; + textColor?: string; + labelColor?: string; + className?: string; + style?: React.CSSProperties; +} + +/** Studio channel fader: absolute-position linear control with a cap handle. */ +export const Fader: React.FC = ({ + orientation = 'vertical', + length = 160, + breadth = 44, + color = '#4cc2ff', + trackColor = 'rgba(255,255,255,0.12)', + showFill = true, + tickCount = 9, + tickColor = 'rgba(255,255,255,0.15)', + label, + showValue = true, + unit, + format, + textColor = 'rgba(255,255,255,0.92)', + labelColor = 'rgba(255,255,255,0.45)', + className, + style, + ...core +}) => { + const vertical = orientation === 'vertical'; + const knob = useKnob({ + ...core, + interaction: vertical ? 'track-vertical' : 'track-horizontal', + }); + const id = React.useId(); + + const capMain = 20; // handle size along the travel axis + const capCross = breadth * 0.62; + const trackW = Math.max(4, breadth * 0.12); + const w = vertical ? breadth : length; + const h = vertical ? length : breadth; + const mid = breadth / 2; + + // Handle center position along the travel axis. + const travel = length - capMain; + const pos = vertical + ? length - capMain / 2 - knob.normalized * travel + : capMain / 2 + knob.normalized * travel; + + const fixed = knob.value.toFixed(knob.decimals); + const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed; + const text = format ? format(knob.value) : `${trimmed}${unit ?? ''}`; + + const ticks: React.ReactNode[] = []; + for (let i = 0; i < tickCount; i++) { + const t = tickCount === 1 ? 0 : i / (tickCount - 1); + const p = vertical ? length - capMain / 2 - t * travel : capMain / 2 + t * travel; + ticks.push( + vertical ? ( + + + + + ) : ( + + + + + ), + ); + } + + return ( +
+ {showValue && ( +
+ + {text} + +
+ )} +
+ +
+ {label && ( +
+ + {label} + +
+ )} +
+ ); +}; diff --git a/packages/dreamknob/src/skins/FlatKnob.tsx b/packages/dreamknob/src/skins/FlatKnob.tsx new file mode 100644 index 0000000..9b07ab6 --- /dev/null +++ b/packages/dreamknob/src/skins/FlatKnob.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { Knob } from '../components/Knob'; +import { Arc } from '../primitives/Arc'; +import { Face } from '../primitives/Face'; +import { Pointer } from '../primitives/Pointer'; +import { KnobLabel, KnobValue } from '../primitives/Text'; +import { MONO_FONT, UI_FONT, type SkinProps } from './shared'; + +export interface FlatKnobProps extends SkinProps { + /** Accent color for the value arc and pointer. */ + color?: string; + trackColor?: string; + faceColor?: string; + pointerColor?: string; + textColor?: string; + labelColor?: string; + /** Draw the value arc from 'min' or from 'center' (pan-style). */ + arcFrom?: 'min' | 'center'; + arcThickness?: number; +} + +/** Clean 2D knob — the modern DAW/plugin look. */ +export const FlatKnob: React.FC = ({ + size = 80, + color = '#4cc2ff', + trackColor = 'rgba(255,255,255,0.12)', + faceColor = 'rgba(255,255,255,0.05)', + pointerColor, + textColor = 'rgba(255,255,255,0.92)', + labelColor = 'rgba(255,255,255,0.45)', + arcFrom = 'min', + arcThickness, + label, + showValue = true, + unit, + format, + className, + style, + ...core +}) => { + const t = arcThickness ?? Math.max(3, size * 0.055); + return ( + + + + + {showValue && ( + + )} + {label && ( + + {label} + + )} + + ); +}; diff --git a/packages/dreamknob/src/skins/LEDFader.tsx b/packages/dreamknob/src/skins/LEDFader.tsx new file mode 100644 index 0000000..038c9b8 --- /dev/null +++ b/packages/dreamknob/src/skins/LEDFader.tsx @@ -0,0 +1,192 @@ +import * as React from 'react'; +import { useKnob } from '../hooks/useKnob'; +import type { KnobCoreProps } from '../core/types'; +import { SegmentDisplay } from '../digital/SegmentDisplay'; +import { UI_FONT } from './shared'; + +export interface LEDFaderZone { + /** Upper bound of the zone as a normalized position (0..1]. */ + upTo: number; + color: string; +} + +export interface LEDFaderProps extends Omit { + orientation?: 'vertical' | 'horizontal'; + /** Travel length in px. Default: 160. */ + length?: number; + /** Cross-axis size in px. Default: 34. */ + breadth?: number; + /** Number of LED segments. Default: 24. */ + segments?: number; + /** LED color when no zones are given. */ + color?: string; + /** + * Meter-style color zones by normalized position, e.g. + * [{ upTo: 0.6, color: '#3df2ad' }, { upTo: 0.85, color: '#ffd23e' }, { upTo: 1, color: '#ff4d6b' }] + */ + zones?: readonly LEDFaderZone[]; + /** Opacity of unlit segments (they keep their zone color). Default: 0.13. */ + offOpacity?: number; + glow?: boolean; + /** Well/panel color behind the LEDs. */ + faceColor?: string; + label?: string; + /** Seven-segment readout above the bar. Default: true. */ + showValue?: boolean; + /** Digit cells in the readout. Default: 4. */ + digits?: number; + /** Decimals in the readout (defaults to the control's decimals, capped at 1). */ + displayDecimals?: number; + labelColor?: string; + className?: string; + style?: React.CSSProperties; +} + +/** Segmented LED fader — an interactive meter-style linear control. */ +export const LEDFader: React.FC = ({ + orientation = 'vertical', + length = 160, + breadth = 34, + segments = 24, + color = '#3df2ad', + zones, + offOpacity = 0.13, + glow = true, + faceColor = '#0b0d0e', + label, + showValue = true, + digits = 4, + displayDecimals, + labelColor = 'rgba(255,255,255,0.45)', + className, + style, + ...core +}) => { + const vertical = orientation === 'vertical'; + const knob = useKnob({ + ...core, + interaction: vertical ? 'track-vertical' : 'track-horizontal', + }); + const id = React.useId(); + const glowId = `${id}-glow`; + + const w = vertical ? breadth : length; + const h = vertical ? length : breadth; + const pad = 5; + const zoneList: readonly LEDFaderZone[] = zones ?? [{ upTo: 1, color }]; + const zoneFor = (t: number): string => + (zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color; + + const lit = Math.round(knob.normalized * segments); + const slot = (length - pad * 2) / segments; + const gap = Math.min(3, slot * 0.35); + const cross = breadth - pad * 2; + + const leds: React.ReactNode[] = []; + for (let i = 0; i < segments; i++) { + const on = i < lit; + const c = zoneFor((i + 1) / segments); + const common = { + rx: 1.5, + fill: c, + opacity: on ? 1 : offOpacity, + filter: on && glow ? `url(#${glowId})` : undefined, + }; + leds.push( + vertical ? ( + + ) : ( + + ), + ); + } + + return ( +
+ {showValue && ( +
+ +
+ )} +
+ +
+ {label && ( +
+ + {label} + +
+ )} +
+ ); +}; diff --git a/packages/dreamknob/src/skins/LEDKnob.tsx b/packages/dreamknob/src/skins/LEDKnob.tsx new file mode 100644 index 0000000..8011207 --- /dev/null +++ b/packages/dreamknob/src/skins/LEDKnob.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { Knob } from '../components/Knob'; +import { GlowFilter } from '../primitives/GlowFilter'; +import { KnobLabel } from '../primitives/Text'; +import { describeArc } from '../core/math'; +import { useKnobContext } from '../core/context'; +import { formatForDisplay, renderSegmentText } from '../digital/segments'; +import { UI_FONT, type SkinProps } from './shared'; + +export interface LEDKnobProps extends SkinProps { + /** LED color. */ + color?: string; + /** Unlit segment color. */ + offColor?: string; + /** Number of LED segments around the travel. */ + segments?: number; + /** Digit cells in the center readout. */ + digits?: number; + /** Decimals shown in the readout (defaults to the knob's decimals, capped at 2). */ + displayDecimals?: number; + labelColor?: string; + faceColor?: string; +} + +const SegmentRing: React.FC<{ color: string; offColor: string; segments: number; glowId: string }> = ({ + color, + offColor, + segments, + glowId, +}) => { + const { size, center: c, normalized, angleOffset, angleRange } = useKnobContext(); + const r = size / 2 - size * 0.065; + const thickness = size * 0.065; + const lit = Math.round(normalized * segments); + const gapDeg = Math.min(3, (angleRange / segments) * 0.3); + const segDeg = angleRange / segments - gapDeg; + + return ( + + {Array.from({ length: segments }, (_, i) => { + const start = angleOffset + (i * angleRange) / segments + gapDeg / 2; + const on = i < lit; + return ( + + ); + })} + + ); +}; + +const CenterDisplay: React.FC<{ + color: string; + digits: number; + decimals: number; + glowId: string; +}> = ({ color, digits, decimals, glowId }) => { + const { size, center: c, value } = useKnobContext(); + const text = formatForDisplay(value, digits, decimals); + const { nodes, width, height } = renderSegmentText(text, { + color, + ghostOpacity: 0.1, + skew: 6, + }); + const targetW = size * 0.46; + const scale = width > 0 ? Math.min(targetW / width, (size * 0.24) / height) : 1; + return ( + + {nodes} + + ); +}; + +/** Digital knob: segmented LED ring around a real seven-segment readout. */ +export const LEDKnob: React.FC = ({ + size = 90, + color = '#3df2ad', + offColor = 'rgba(255,255,255,0.07)', + segments = 24, + digits = 3, + displayDecimals, + labelColor = 'rgba(255,255,255,0.45)', + faceColor = '#0b0d0e', + label, + className, + style, + ...core +}) => { + const id = React.useId(); + const glowId = `${id}-glow`; + return ( + + {ctx => ( + <> + + + + + + + {label && ( + + {label} + + )} + + )} + + ); +}; diff --git a/packages/dreamknob/src/skins/MetalKnob.tsx b/packages/dreamknob/src/skins/MetalKnob.tsx new file mode 100644 index 0000000..129509d --- /dev/null +++ b/packages/dreamknob/src/skins/MetalKnob.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; +import { Knob } from '../components/Knob'; +import { Arc } from '../primitives/Arc'; +import { Pointer } from '../primitives/Pointer'; +import { Ticks } from '../primitives/Ticks'; +import { KnobLabel } from '../primitives/Text'; +import { UI_FONT, type SkinProps } from './shared'; + +export interface MetalKnobProps extends SkinProps { + /** Accent color for the active ticks / value arc. */ + color?: string; + /** Overall metal tone. 'silver' | 'dark' or any base hex for the cap. */ + tone?: 'silver' | 'dark'; + tickColor?: string; + labelColor?: string; + indicatorColor?: string; + showArc?: boolean; + tickCount?: number; +} + +/** 3D brushed-aluminum knob with a knurled rim — classic hi-fi hardware. */ +export const MetalKnob: React.FC = ({ + size = 90, + color = '#4cc2ff', + tone = 'silver', + tickColor = 'rgba(255,255,255,0.18)', + labelColor = 'rgba(255,255,255,0.45)', + indicatorColor, + showArc = true, + tickCount = 21, + label, + className, + style, + ...core +}) => { + const id = React.useId(); + const rimId = `${id}-rim`; + const faceId = `${id}-face`; + const shadowId = `${id}-shadow`; + const c = size / 2; + const rimR = size / 2 - size * 0.14; + const faceR = rimR * 0.78; + + const silver = tone === 'silver'; + const rimStops = silver + ? ['#f4f5f7', '#b9bbc2', '#63656d', '#33343a'] + : ['#6a6c74', '#43444b', '#232429', '#101114']; + const faceStops = silver + ? ['#fbfcfd', '#d3d5da', '#9c9ea6'] + : ['#585a63', '#33343b', '#1b1c21']; + const indicator = indicatorColor ?? (silver ? '#1b1c21' : color); + + return ( + + + + + + + + + + + + + + + + + + + {showArc && ( + + )} + + + {/* cap */} + + + + {/* knurled rim */} + + + {/* concentric machining lines */} + + + + + {label && ( + + {label} + + )} + + ); +}; diff --git a/packages/dreamknob/src/skins/NeonKnob.tsx b/packages/dreamknob/src/skins/NeonKnob.tsx new file mode 100644 index 0000000..a8f2a46 --- /dev/null +++ b/packages/dreamknob/src/skins/NeonKnob.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { Knob } from '../components/Knob'; +import { Arc } from '../primitives/Arc'; +import { Pointer } from '../primitives/Pointer'; +import { KnobLabel, KnobValue } from '../primitives/Text'; +import { MONO_FONT, UI_FONT, type SkinProps } from './shared'; + +export interface NeonKnobProps extends SkinProps { + color?: string; + trackColor?: string; + textColor?: string; + labelColor?: string; + arcFrom?: 'min' | 'center'; +} + +/** Minimal glowing arc knob — futuristic / dark-mode DAW style. */ +export const NeonKnob: React.FC = ({ + size = 84, + color = '#e44cff', + trackColor = 'rgba(255,255,255,0.06)', + textColor, + labelColor = 'rgba(255,255,255,0.4)', + arcFrom = 'min', + label, + showValue = true, + unit, + format, + className, + style, + ...core +}) => { + const id = React.useId(); + const glowId = `${id}-glow`; + return ( + + + + + + + + + + + + + + + {showValue && ( + + )} + {label && ( + + {label} + + )} + + ); +}; diff --git a/packages/dreamknob/src/skins/RubberKnob.tsx b/packages/dreamknob/src/skins/RubberKnob.tsx new file mode 100644 index 0000000..ab13efd --- /dev/null +++ b/packages/dreamknob/src/skins/RubberKnob.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { Knob } from '../components/Knob'; +import { Arc } from '../primitives/Arc'; +import { GlowFilter } from '../primitives/GlowFilter'; +import { Pointer } from '../primitives/Pointer'; +import { KnobLabel, KnobValue } from '../primitives/Text'; +import { MONO_FONT, UI_FONT, type SkinProps } from './shared'; + +export interface RubberKnobProps extends SkinProps { + /** Glow/accent color. */ + color?: string; + trackColor?: string; + textColor?: string; + labelColor?: string; + arcFrom?: 'min' | 'center'; +} + +/** Soft-touch rubber knob with a glowing halo — modern synth hardware. */ +export const RubberKnob: React.FC = ({ + size = 90, + color = '#ff9640', + trackColor = 'rgba(255,255,255,0.08)', + textColor = 'rgba(255,255,255,0.92)', + labelColor = 'rgba(255,255,255,0.45)', + arcFrom = 'min', + label, + showValue = false, + unit, + format, + className, + style, + ...core +}) => { + const id = React.useId(); + const bodyId = `${id}-body`; + const glowId = `${id}-glow`; + const c = size / 2; + const bodyR = size / 2 - size * 0.17; + + return ( + + + + + + + + + + + + + + {/* rubber grip */} + + {/* top sheen */} + + + + {showValue && ( + + )} + {label && ( + + {label} + + )} + + ); +}; diff --git a/packages/dreamknob/src/skins/SteppedKnob.tsx b/packages/dreamknob/src/skins/SteppedKnob.tsx new file mode 100644 index 0000000..847c5a1 --- /dev/null +++ b/packages/dreamknob/src/skins/SteppedKnob.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { Knob } from '../components/Knob'; +import { Pointer } from '../primitives/Pointer'; +import { Ticks } from '../primitives/Ticks'; +import { KnobLabel } from '../primitives/Text'; +import { MONO_FONT, UI_FONT, type SkinProps } from './shared'; + +export interface SteppedKnobProps extends SkinProps { + /** Position names, e.g. ['LP','BP','HP']. Sets the number of detents. */ + positions?: readonly string[]; + /** Number of detents when `positions` is not given. */ + steps?: number; + color?: string; + tickColor?: string; + faceColor?: string; + textColor?: string; + labelColor?: string; +} + +/** Detented selector knob — mode/range switches with hard stops. */ +export const SteppedKnob: React.FC = ({ + size = 90, + positions, + steps, + color = '#ffd23e', + tickColor = 'rgba(255,255,255,0.18)', + faceColor = '#1c1d22', + textColor = 'rgba(255,255,255,0.92)', + labelColor = 'rgba(255,255,255,0.45)', + label, + showValue = true, + className, + style, + ...core +}) => { + const detents = positions ? positions.length : steps ?? 5; + const min = core.min ?? 0; + const max = core.max ?? (positions ? positions.length - 1 : 100); + + return ( + + {ctx => { + const index = Math.round(ctx.normalized * (detents - 1)); + return ( + <> + (i === index ? { stroke: color } : undefined)} + /> + + + {showValue && ( + + {positions ? positions[index] : ctx.value.toFixed(ctx.decimals)} + + )} + {label && ( + + {label} + + )} + + ); + }} + + ); +}; diff --git a/packages/dreamknob/src/skins/VintageKnob.tsx b/packages/dreamknob/src/skins/VintageKnob.tsx new file mode 100644 index 0000000..532be6f --- /dev/null +++ b/packages/dreamknob/src/skins/VintageKnob.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { Knob } from '../components/Knob'; +import { Ticks } from '../primitives/Ticks'; +import { KnobLabel } from '../primitives/Text'; +import { polarToCartesian } from '../core/math'; +import { useKnobContext } from '../core/context'; +import { UI_FONT, type SkinProps } from './shared'; + +export interface VintageKnobProps extends SkinProps { + /** Knob body color. Classic options: cream '#efe6d0' or bakelite '#26221f'. */ + bodyColor?: string; + /** Tick/scale color. */ + scaleColor?: string; + /** Accent for active ticks. */ + color?: string; + labelColor?: string; + /** Draw numeric scale labels (0..10 style) around the knob. */ + scaleLabels?: readonly string[]; + /** Indicator line color. Defaults to a contrast pick based on bodyColor. */ + indicatorColor?: string; +} + +/** Rough luminance check so the indicator stays visible on any body color. */ +const isDarkColor = (color: string): boolean => { + const m = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!m) return false; + let h = m[1]; + if (h.length === 3) h = h.split('').map(c => c + c).join(''); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110; +}; + +/** Chicken-head pointer knob rotated by the current angle. */ +const ChickenHead: React.FC<{ bodyColor: string; indicatorColor: string }> = ({ + bodyColor, + indicatorColor, +}) => { + const { size, center: c, angle } = useKnobContext(); + const id = React.useId(); + const bodyR = size * 0.26; + const noseR = size * 0.42; + + // Body circle with a pointed nose reaching up to noseR, authored at 12 + // o'clock. Two shoulder points at ±38° blend the nose into the circle. + const left = polarToCartesian(c, c, bodyR, -38); + const right = polarToCartesian(c, c, bodyR, 38); + const tip = { x: c, y: c - noseR }; + + return ( + + + + + + + + + + + + + + + {/* indicator line down the nose */} + + {/* center screw */} + + + + ); +}; + +const ScaleLabels: React.FC<{ labels: readonly string[]; color: string }> = ({ labels, color }) => { + const { size, center: c, angleOffset, angleRange } = useKnobContext(); + return ( + + {labels.map((text, i) => { + const t = labels.length === 1 ? 0 : i / (labels.length - 1); + const p = polarToCartesian(c, c, size / 2 - size * 0.02, angleOffset + t * angleRange); + return ( + + {text} + + ); + })} + + ); +}; + +/** Vintage amp-style knob: chicken-head pointer over a printed tick scale. */ +export const VintageKnob: React.FC = ({ + size = 96, + bodyColor = '#efe6d0', + scaleColor = 'rgba(255,255,255,0.35)', + color, + labelColor = 'rgba(255,255,255,0.45)', + scaleLabels, + indicatorColor, + label, + className, + style, + ...core +}) => ( + + + {scaleLabels && } + + {label && ( + + {label} + + )} + +); diff --git a/packages/dreamknob/src/skins/shared.ts b/packages/dreamknob/src/skins/shared.ts new file mode 100644 index 0000000..48aaf8e --- /dev/null +++ b/packages/dreamknob/src/skins/shared.ts @@ -0,0 +1,23 @@ +import type * as React from 'react'; +import type { KnobCoreProps } from '../core/types'; + +/** Props common to every prebuilt knob skin. */ +export interface SkinProps extends KnobCoreProps { + /** Square canvas size in px. */ + size?: number; + /** Caption drawn in the travel gap at the bottom of the knob. */ + label?: string; + /** Show the numeric readout. Default varies per skin. */ + showValue?: boolean; + /** Unit suffix for the readout, e.g. "dB", "Hz", "%". */ + unit?: string; + /** Custom readout formatter (takes precedence over unit/decimals). */ + format?: (value: number) => string; + className?: string; + style?: React.CSSProperties; +} + +export const MONO_FONT = + "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; +export const UI_FONT = + "'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"; diff --git a/packages/dreamknob/tsconfig.json b/packages/dreamknob/tsconfig.json new file mode 100644 index 0000000..a751ad3 --- /dev/null +++ b/packages/dreamknob/tsconfig.json @@ -0,0 +1,19 @@ +{ + "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, + "declaration": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e051e6e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1953 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + apps/docs: + dependencies: + dreamknob: + specifier: workspace:* + version: link:../../packages/dreamknob + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/node': + specifier: ^20.14.10 + version: 20.19.43 + '@types/react': + specifier: ^18.3.3 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.7.0(vite@5.4.21(@types/node@20.19.43)) + typescript: + specifier: ^5.5.3 + version: 5.9.3 + vite: + specifier: ^5.3.3 + version: 5.4.21(@types/node@20.19.43) + + packages/dreamknob: + devDependencies: + '@types/react': + specifier: ^18.3.3 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + tsup: + specifier: ^8.1.0 + version: 8.5.1(postcss@8.5.18)(typescript@5.9.3) + typescript: + specifier: ^5.5.3 + version: 5.9.3 + vitest: + specifier: ^2.0.3 + version: 2.1.9(@types/node@20.19.43) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001805: + resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + electron-to-chromium@1.5.389: + resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.43))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.43))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.43) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + acorn@8.17.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + baseline-browser-mapping@2.10.43: {} + + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001805 + electron-to-chromium: 1.5.389 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + caniuse-lite@1.0.30001805: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + electron-to-chromium@1.5.389: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + joycon@3.1.1: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + object-assign@4.1.1: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.18): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.18 + + postcss@8.5.18: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.18)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.18) + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.18 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite-node@2.1.9(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.43): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.18 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@20.19.43): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.43)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.19.43) + vite-node: 2.1.9(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6f00273 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - packages/* + - apps/*