diff --git a/apps/web/biome.json b/apps/web/biome.json index 6d81bbe..98aa695 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -21,7 +21,13 @@ "enabled": true, "indentStyle": "tab" }, - "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, "linter": { "enabled": true, "rules": { @@ -43,7 +49,8 @@ { "includes": [ "**/components/dither-kit/**/*.tsx", - "**/routes/dither-kit.tsx" + "**/routes/dither-kit.tsx", + "**/components/dither-kit-docs/**/*.tsx" ], "linter": { "rules": { diff --git a/apps/web/package.json b/apps/web/package.json index aa8e4eb..ac805cb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -72,6 +72,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", + "dialkit": "^1.3.0", "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", "evlog": "^2.17.0", diff --git a/apps/web/src/components/dither-kit-docs/content.ts b/apps/web/src/components/dither-kit-docs/content.ts new file mode 100644 index 0000000..1f4f465 --- /dev/null +++ b/apps/web/src/components/dither-kit-docs/content.ts @@ -0,0 +1,178 @@ +import type { + AreaVariant, + BloomInput, + ChartConfig, +} from "#/components/dither-kit" + +/** Everything the dither-kit docs page renders from: install commands, demo + * data, the tweak model, and the live code snippets. */ + +/** Where the registry lives once deployed. */ +export const HOST = "https://tripwire.sh" + +/* ------------------------------------------------------- package manager */ + +export const PMS = ["npm", "pnpm", "yarn", "bun"] as const +export type Pm = (typeof PMS)[number] + +/** The runner each package manager uses for one-off CLIs. */ +const PM_RUNNER: Record = { + npm: "npx", + pnpm: "pnpm dlx", + yarn: "yarn dlx", + bun: "bunx --bun", +} + +export const addCmd = (pm: Pm, item: string): string => + `${PM_RUNNER[pm]} shadcn@latest add ${item}` + +/* ------------------------------------------------------------------ data */ + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"] +export const series = MONTHS.map((month, i) => ({ + month, + desktop: Math.round(120 + 90 * Math.sin(i * 0.7) + i * 14), + mobile: Math.round(70 + 50 * Math.cos(i * 0.5) + i * 8), +})) +export const config: ChartConfig = { + desktop: { label: "Desktop", color: "blue" }, + mobile: { label: "Mobile", color: "purple" }, +} +export const pieData = [ + { browser: "chrome", visitors: 275 }, + { browser: "safari", visitors: 200 }, + { browser: "firefox", visitors: 187 }, + { browser: "edge", visitors: 120 }, + { browser: "other", visitors: 90 }, +] +export const pieConfig: ChartConfig = { + chrome: { label: "Chrome", color: "blue" }, + safari: { label: "Safari", color: "green" }, + firefox: { label: "Firefox", color: "orange" }, + edge: { label: "Edge", color: "purple" }, + other: { label: "Other", color: "grey" }, +} +export const radarData = [ + { skill: "Speed", desktop: 186, mobile: 120 }, + { skill: "Power", desktop: 205, mobile: 98 }, + { skill: "Range", desktop: 137, mobile: 160 }, + { skill: "Defense", desktop: 173, mobile: 125 }, + { skill: "Magic", desktop: 160, mobile: 190 }, + { skill: "Luck", desktop: 144, mobile: 110 }, +] + +/* ---------------------------------------------------------- live tweaks */ + +export const VARIANTS: AreaVariant[] = [ + "gradient", + "dotted", + "hatched", + "solid", +] +export const BLOOM_PRESETS = ["off", "low", "high", "aura", "custom"] as const +export type BloomPreset = (typeof BLOOM_PRESETS)[number] + +export type Tweaks = { + bloomPreset: BloomPreset + blur: number + brightness: number + opacity: number + saturate: number + primaryVariant: AreaVariant + secondaryVariant: AreaVariant + stacked: boolean + donutRadius: number // 0 = full pie + duration: number // entrance ms +} + +export function bloomOf(t: Tweaks): BloomInput { + return t.bloomPreset === "custom" + ? { + blur: t.blur, + brightness: t.brightness, + opacity: t.opacity, + saturate: t.saturate, + } + : t.bloomPreset +} + +/* ------------------------------------------------------------- snippets */ + +/** How the current bloom reads in the code snippets. */ +function bloomAttr(t: Tweaks): string { + if (t.bloomPreset === "custom") { + return ` bloom={{ blur: ${t.blur}, brightness: ${t.brightness}, opacity: ${t.opacity}, saturate: ${t.saturate} }}` + } + return t.bloomPreset === "off" ? "" : ` bloom="${t.bloomPreset}"` +} + +const stackAttr = (t: Tweaks): string => + t.stacked ? ' stackType="stacked"' : "" + +/** Non-default entrance timing shows up in the copied code too. */ +const durationAttr = (t: Tweaks): string => + t.duration === 900 ? "" : ` animationDuration={${t.duration}}` + +export const areaCode = (t: Tweaks): string => + ` + + + + + + +` + +export const barCode = (t: Tweaks): string => + ` + + + + + + +` + +export const lineCode = (t: Tweaks): string => + `// LineChart ships in the area-chart item (line = area + glow) + + + + + + + +` + +export const pieCode = (t: Tweaks): string => + ` 0 ? ` innerRadius={${t.donutRadius}}` : "" + }${bloomAttr(t)}${durationAttr(t)}> + + + +` + +export const radarCode = (t: Tweaks): string => + ` + + + + +` + +export const PROPS: [string, string][] = [ + ["variant", '"gradient" | "dotted" | "hatched" | "solid"'], + [ + "bloom", + '"off" | "low" | "high" | "aura" | { blur, brightness, opacity, saturate }', + ], + ["stackType", '"default" | "stacked" | "percent"'], + ["colors", "green blue purple pink orange red grey"], + ["animate", "animationDuration + replayToken for entrance and replay"], + ["interactive", "false = decorative spark, no crosshair or tooltip"], +] + +export const SPARKLINE_CODE = `// tiny decorative spark — no axes, no tooltip +` diff --git a/apps/web/src/components/dither-kit-docs/sections.tsx b/apps/web/src/components/dither-kit-docs/sections.tsx new file mode 100644 index 0000000..d465857 --- /dev/null +++ b/apps/web/src/components/dither-kit-docs/sections.tsx @@ -0,0 +1,441 @@ +"use client" + +import { MoonIcon, SunIcon } from "lucide-react" +import { + ActiveDot, + Area, + AreaChart, + Bar, + BarChart, + type BloomInput, + Legend, + Line, + LineChart, + Pie, + PieChart, + Radar, + RadarChart, + Tooltip, + XAxis, + YAxis, +} from "#/components/dither-kit" +import { + addCmd, + areaCode, + barCode, + config, + HOST, + lineCode, + pieCode, + pieConfig, + pieData, + type Pm, + PMS, + PROPS, + radarCode, + radarData, + series, + SPARKLINE_CODE, + type Tweaks, +} from "./content" +import { + Code, + CodeBlock, + CopyLine, + DitherStrip, + Pill, + ReplayButton, + Showcase, +} from "./ui" + +/** The docs page, section by section — the route composes these. */ + +export type Replays = { + hero: number + area: number + bar: number + line: number + pie: number + radar: number +} +export type ReplayKey = keyof Replays + +export function HeroSection({ + light, + onToggleTheme, + tweaks, + bloom, + replayToken, + onReplay, +}: { + light: boolean + onToggleTheme: () => void + tweaks: Tweaks + bloom: BloomInput + replayToken: number + onReplay: () => void +}) { + return ( +
+
+
+

+ dither-kit +

+

+ five chart types on one tiny canvas engine, no recharts +

+
+ +
+ +

+ Composable, dithered charts + with a recharts-style children-as-config API. Ordered-dither fills that + hold up in light and dark, entrance animations, a gliding scrub tooltip, + selection, winking sparkles, and colour bloom. +

+ + {/* Hero chart */} +
+
+ + scrub it, hover a legend entry to spotlight that series, click to + lock it + + +
+
+ + + + + + + + + + + + +
+
+
+ ) +} + +export function InstallSection({ + pm, + onPmChange, +}: { + pm: Pm + onPmChange: (pm: Pm) => void +}) { + // Register the namespace once, then install any chart by name. + const registries = `// components.json\n{\n "registries": {\n "@dither-kit": "${HOST}/r/{name}.json"\n }\n}` + return ( +
+
+

install

+ +
+ {PMS.map((p) => ( + onPmChange(p)} + /> + ))} +
+
+ +
+
+ + 1. register the namespace in{" "} + components.json + + +
+
+ + 2. add charts — each pulls{" "} + @dither-kit/core{" "} + automatically + +
+ + + +
+
+
+ +

+ also available: bar-chart and{" "} + radar-chart.{" "} + @dither-kit/dither-kit grabs + everything. skipping the namespace config? the raw URL works:{" "} + + shadcn add {HOST}/r/radar-chart.json + + . files land in{" "} + components/dither-kit/. +

+
+ ) +} + +export function CreditAside() { + return ( + + ) +} + +export function ChartGallery({ + pm, + tweaks, + bloom, + replays, + onReplay, +}: { + pm: Pm + tweaks: Tweaks + bloom: BloomInput + replays: Replays + onReplay: (key: ReplayKey) => void +}) { + return ( +
+
+

charts

+ + + tweak these charts from the floating dial panel → + +
+ + onReplay("area")} />} + > + + + + + + + + + + + onReplay("bar")} />} + > + + + + + + + + + + + onReplay("line")} />} + > + + + + + + + + + + +
+ onReplay("pie")} />} + > + + + + + + + + onReplay("radar")} />} + > + + + + + + + +
+
+ ) +} + +export function KnobsSection() { + return ( +
+
+

knobs

+ +
+
+ {PROPS.map(([prop, desc]) => ( +
+
+ + {prop} + +
+
+ +
+
+ ))} +
+ +
+ ) +} + +export function DocsFooter() { + return ( + + ) +} diff --git a/apps/web/src/components/dither-kit-docs/ui.tsx b/apps/web/src/components/dither-kit-docs/ui.tsx new file mode 100644 index 0000000..7ffc1d3 --- /dev/null +++ b/apps/web/src/components/dither-kit-docs/ui.tsx @@ -0,0 +1,300 @@ +"use client" + +import { CheckIcon, CopyIcon, RefreshCcwIcon } from "lucide-react" +import { Fragment, type ReactNode, useState } from "react" + +/** The dither-kit docs page's building blocks: the checkerboard strip motif, + * copy affordances, the tiny syntax highlighter, and the showcase card. */ + +/** The page's signature motif: a strip of ordered-dither checkerboard that + * fades out — the same texture the charts are made of, as page chrome. */ +export function DitherStrip({ className = "" }: { className?: string }) { + return ( +
+ ) +} + +/* ---------------------------------------------------------------- theme */ + +export type PageThemeState = { + light: boolean + toggle: () => void +} + +// Storage can be disabled (private mode, embedded webviews) — the toggle +// keeps working in memory either way. +const readPageTheme = (): boolean => { + try { + return window.localStorage.getItem("dither-kit-theme") === "light" + } catch { + return false + } +} + +const writePageTheme = (light: boolean): void => { + try { + window.localStorage.setItem("dither-kit-theme", light ? "light" : "dark") + } catch { + // Keep the in-memory toggle working. + } +} + +/** Page-scoped theme. The app is dark-only, so instead of a global theme the + * page swaps the `.dither-light` token overrides (and the `dark` class that + * drives `dark:` variants) on its own wrapper. Persisted per visitor. */ +export function usePageTheme(): PageThemeState { + const [light, setLight] = useState( + () => typeof window !== "undefined" && readPageTheme() + ) + const toggle = () => { + setLight((value) => { + const next = !value + writePageTheme(next) + return next + }) + } + return { light, toggle } +} + +/* ----------------------------------------------------------------- copy */ + +function useCopy() { + const [copied, setCopied] = useState(false) + const copy = (text: string) => { + navigator.clipboard?.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1400) + } + return { copied, copy } +} + +/** A copyable terminal line. */ +export function CopyLine({ text }: { text: string }) { + const { copied, copy } = useCopy() + return ( + + ) +} + +/* --------------------------------------------------- syntax highlighting */ + +type TokenType = "comment" | "string" | "tag" | "attr" | "number" | "punct" + +const TOKEN_RE = + /(\/\/[^\n]*)|("(?:[^"\\]|\\.)*")|(<\/?[A-Za-z][\w.]*|\/?>)|(\b\d+(?:\.\d+)?\b|\btrue\b|\bfalse\b)|([A-Za-z_]\w*(?==))|([{}()[\]=|,:])/g + +const TOKEN_CLASS: Record = { + comment: "text-muted-foreground/60 italic", + string: "text-emerald-600 dark:text-emerald-400", + tag: "text-sky-600 dark:text-sky-400", + number: "text-orange-600 dark:text-orange-400", + attr: "text-violet-600 dark:text-violet-400", + punct: "text-muted-foreground/70", +} + +type Token = { type: TokenType | null; text: string; start: number } + +/** Pure tokenizer — `matchAll` leaves the shared regex untouched, and each + * token carries its source offset for a stable render key. */ +function tokenize(code: string): Token[] { + const tokens: Token[] = [] + let last = 0 + for (const m of code.matchAll(TOKEN_RE)) { + const start = m.index ?? 0 + if (start > last) + tokens.push({ type: null, text: code.slice(last, start), start: last }) + const type: TokenType = m[1] + ? "comment" + : m[2] + ? "string" + : m[3] + ? "tag" + : m[4] + ? "number" + : m[5] + ? "attr" + : "punct" + tokens.push({ type, text: m[0], start }) + last = start + m[0].length + } + if (last < code.length) + tokens.push({ type: null, text: code.slice(last), start: last }) + return tokens +} + +/** Tiny JSX-ish highlighter for the docs snippets — no dependency, themed to + * the dither palette, adapts to light and dark via classes. */ +export function Code({ code }: { code: string }) { + return ( + <> + {tokenize(code).map((t) => + t.type ? ( + + {t.text} + + ) : ( + {t.text} + ) + )} + + ) +} + +export function CodeBlock({ code }: { code: string }) { + const { copied, copy } = useCopy() + return ( +
+
+        
+          
+        
+      
+ +
+ ) +} + +/* ---------------------------------------------------------------- pills */ + +/** Tiny pill toggle used for tabs and boolean tweaks. */ +export function Pill({ + label, + active, + onClick, +}: { + label: string + active?: boolean + onClick: () => void +}) { + return ( + + ) +} + +export function ReplayButton({ onClick }: { onClick: () => void }) { + return ( + + ) +} + +/* -------------------------------------------------------------- showcase */ + +export function Showcase({ + title, + install, + code, + toolbar, + children, + tall = false, +}: { + title: string + install: string + code: string + /** Extra controls rendered in the card toolbar (e.g. replay). */ + toolbar?: ReactNode + children: ReactNode + tall?: boolean +}) { + const [tab, setTab] = useState<"preview" | "code">("preview") + const { copied, copy } = useCopy() + return ( +
+ {/* Header row */} +
+

{title}

+ +
+ {toolbar} + setTab("preview")} + /> + setTab("code")} + /> +
+
+ + {/* Body */} + {tab === "preview" ? ( +
{children}
+ ) : ( +
+ +
+ )} +
+ ) +} diff --git a/apps/web/src/routes/dither-kit.tsx b/apps/web/src/routes/dither-kit.tsx index b2aaa3e..8b8fe52 100644 --- a/apps/web/src/routes/dither-kit.tsx +++ b/apps/web/src/routes/dither-kit.tsx @@ -1,670 +1,38 @@ import { createFileRoute } from "@tanstack/react-router" +import { DialRoot, useDialKit } from "dialkit" +import "dialkit/styles.css" +import { useState } from "react" +import type { AreaVariant } from "#/components/dither-kit" import { - CheckIcon, - CopyIcon, - RefreshCcwIcon, - SlidersHorizontalIcon, - XIcon, -} from "lucide-react" -import { Fragment, type ReactNode, useEffect, useRef, useState } from "react" + type BloomPreset, + bloomOf, + type Pm, + type Tweaks, + VARIANTS, +} from "#/components/dither-kit-docs/content" import { - ActiveDot, - Area, - AreaChart, - type AreaVariant, - Bar, - BarChart, - type BloomInput, - type ChartConfig, - Legend, - Line, - LineChart, - Pie, - PieChart, - Radar, - RadarChart, - Tooltip, - XAxis, - YAxis, -} from "#/components/dither-kit" + ChartGallery, + CreditAside, + DocsFooter, + HeroSection, + InstallSection, + KnobsSection, + type ReplayKey, + type Replays, +} from "#/components/dither-kit-docs/sections" +import { DitherStrip, usePageTheme } from "#/components/dither-kit-docs/ui" export const Route = createFileRoute("/dither-kit")({ ssr: false, component: DitherKitDocs, }) -/** Where the registry lives once deployed. */ -const HOST = "https://tripwire.sh" - -/* ------------------------------------------------------- package manager */ - -const PMS = ["npm", "pnpm", "yarn", "bun"] as const -type Pm = (typeof PMS)[number] - -/** The runner each package manager uses for one-off CLIs. */ -const PM_RUNNER: Record = { - npm: "npx", - pnpm: "pnpm dlx", - yarn: "yarn dlx", - bun: "bunx --bun", -} - -const addCmd = (pm: Pm, item: string) => - `${PM_RUNNER[pm]} shadcn@latest add ${item}` - -/* ------------------------------------------------------------------ data */ - -const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"] -const series = MONTHS.map((month, i) => ({ - month, - desktop: Math.round(120 + 90 * Math.sin(i * 0.7) + i * 14), - mobile: Math.round(70 + 50 * Math.cos(i * 0.5) + i * 8), -})) -const config: ChartConfig = { - desktop: { label: "Desktop", color: "blue" }, - mobile: { label: "Mobile", color: "purple" }, -} -const pieData = [ - { browser: "chrome", visitors: 275 }, - { browser: "safari", visitors: 200 }, - { browser: "firefox", visitors: 187 }, - { browser: "edge", visitors: 120 }, - { browser: "other", visitors: 90 }, -] -const pieConfig: ChartConfig = { - chrome: { label: "Chrome", color: "blue" }, - safari: { label: "Safari", color: "green" }, - firefox: { label: "Firefox", color: "orange" }, - edge: { label: "Edge", color: "purple" }, - other: { label: "Other", color: "grey" }, -} -const radarData = [ - { skill: "Speed", desktop: 186, mobile: 120 }, - { skill: "Power", desktop: 205, mobile: 98 }, - { skill: "Range", desktop: 137, mobile: 160 }, - { skill: "Defense", desktop: 173, mobile: 125 }, - { skill: "Magic", desktop: 160, mobile: 190 }, - { skill: "Luck", desktop: 144, mobile: 110 }, -] - -/* ---------------------------------------------------------- live tweaks */ - -const VARIANTS: AreaVariant[] = ["gradient", "dotted", "hatched", "solid"] -const BLOOM_PRESETS = ["off", "low", "high", "aura", "custom"] as const -type BloomPreset = (typeof BLOOM_PRESETS)[number] - -type Tweaks = { - bloomPreset: BloomPreset - blur: number - brightness: number - opacity: number - saturate: number - primaryVariant: AreaVariant - secondaryVariant: AreaVariant - stacked: boolean - donutRadius: number // 0 = full pie - duration: number // entrance ms -} - -const DEFAULT_TWEAKS: Tweaks = { - bloomPreset: "aura", - blur: 24, - brightness: 2.9, - opacity: 0.1, - saturate: 3, - primaryVariant: "gradient", - secondaryVariant: "hatched", - stacked: true, - donutRadius: 0.5, - duration: 900, -} - -function bloomOf(t: Tweaks): BloomInput { - return t.bloomPreset === "custom" - ? { - blur: t.blur, - brightness: t.brightness, - opacity: t.opacity, - saturate: t.saturate, - } - : t.bloomPreset -} - -/** How the current bloom reads in the code snippets. */ -function bloomAttr(t: Tweaks): string { - if (t.bloomPreset === "custom") { - return ` bloom={{ blur: ${t.blur}, brightness: ${t.brightness}, opacity: ${t.opacity}, saturate: ${t.saturate} }}` - } - return t.bloomPreset === "off" ? "" : ` bloom="${t.bloomPreset}"` -} - -/* ------------------------------------------------------------ primitives */ - -/** The page's signature motif: a strip of ordered-dither checkerboard that - * fades out — the same texture the charts are made of, as page chrome. */ -function DitherStrip({ className = "" }: { className?: string }) { - return ( -
- ) -} - -/** Fire `onSettle` once `value` has stopped changing for `ms` (skips mount). - * Lets the tweak panel replay the charts by itself instead of making you reach - * for the replay button after every change. */ -function useSettled(value: string | number, ms: number, onSettle: () => void) { - const first = useRef(true) - const settle = useRef(onSettle) - useEffect(() => { - settle.current = onSettle - }, [onSettle]) - useEffect(() => { - if (first.current) { - first.current = false - return - } - const t = setTimeout(() => settle.current(), ms) - return () => clearTimeout(t) - }, [value, ms]) -} - -function useCopy() { - const [copied, setCopied] = useState(false) - const copy = (text: string) => { - navigator.clipboard?.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 1400) - } - return { copied, copy } -} - -/** A copyable terminal line. */ -function CopyLine({ text }: { text: string }) { - const { copied, copy } = useCopy() - return ( - - ) -} - -/* --------------------------------------------------- syntax highlighting */ - -type TokenType = "comment" | "string" | "tag" | "attr" | "number" | "punct" - -const TOKEN_RE = - /(\/\/[^\n]*)|("(?:[^"\\]|\\.)*")|(<\/?[A-Za-z][\w.]*|\/?>)|(\b\d+(?:\.\d+)?\b|\btrue\b|\bfalse\b)|([A-Za-z_]\w*(?==))|([{}()[\]=|,:])/g - -const TOKEN_CLASS: Record = { - comment: "text-muted-foreground/60 italic", - string: "text-emerald-600 dark:text-emerald-400", - tag: "text-sky-600 dark:text-sky-400", - number: "text-orange-600 dark:text-orange-400", - attr: "text-violet-600 dark:text-violet-400", - punct: "text-muted-foreground/70", -} - -type Token = { type: TokenType | null; text: string; start: number } - -/** Pure tokenizer — `matchAll` leaves the shared regex untouched, and each - * token carries its source offset for a stable render key. */ -function tokenize(code: string): Token[] { - const tokens: Token[] = [] - let last = 0 - for (const m of code.matchAll(TOKEN_RE)) { - const start = m.index ?? 0 - if (start > last) - tokens.push({ type: null, text: code.slice(last, start), start: last }) - const type: TokenType = m[1] - ? "comment" - : m[2] - ? "string" - : m[3] - ? "tag" - : m[4] - ? "number" - : m[5] - ? "attr" - : "punct" - tokens.push({ type, text: m[0], start }) - last = start + m[0].length - } - if (last < code.length) - tokens.push({ type: null, text: code.slice(last), start: last }) - return tokens -} - -/** Tiny JSX-ish highlighter for the docs snippets — no dependency, themed to - * the dither palette, adapts to light and dark via classes. */ -function Code({ code }: { code: string }) { - return ( - <> - {tokenize(code).map((t) => - t.type ? ( - - {t.text} - - ) : ( - {t.text} - ) - )} - - ) -} - -function CodeBlock({ code }: { code: string }) { - const { copied, copy } = useCopy() - return ( -
-
-        
-          
-        
-      
- -
- ) -} - -/** Tiny pill toggle used for tabs and boolean tweaks. */ -function Pill({ - label, - active, - onClick, -}: { - label: string - active?: boolean - onClick: () => void -}) { - return ( - - ) -} - -function ReplayButton({ onClick }: { onClick: () => void }) { - return ( - - ) -} - -/* --------------------------------------------------------- tweak sidebar */ - -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( - - ) -} - -function Select({ - value, - options, - onChange, - ariaLabel, -}: { - value: T - options: readonly T[] - onChange: (v: T) => void - ariaLabel: string -}) { - return ( - - ) -} - -function Range({ - value, - min, - max, - step, - onChange, - ariaLabel, -}: { - value: number - min: number - max: number - step: number - onChange: (v: number) => void - ariaLabel: string -}) { - return ( -
- onChange(Number(e.target.value))} - className="w-full accent-foreground" - /> - - {value} - -
- ) -} - -function TweakSidebar({ - open, - onClose, - tweaks, - setTweaks, - onReplayAll, -}: { - open: boolean - onClose: () => void - tweaks: Tweaks - setTweaks: (t: Tweaks) => void - onReplayAll: () => void -}) { - const set = (key: K, value: Tweaks[K]) => - setTweaks({ ...tweaks, [key]: value }) - - // Docked, not modal — the page stays visible and interactive alongside, so - // you can tweak, watch the charts respond, and tweak again. - return ( -