From c3fb63a113bc860d0d49cb30b17ed94e05ba7224 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 29 Apr 2026 15:38:55 +0300 Subject: [PATCH] feat(playground): UI primitive library + /dev/primitives showcase Sixteen primitives at src/primitives/, each with its own .module.css and a .demo.tsx that wires the primitive to live local state. All primitives are typed (strict), pointer-event based (touch + mouse), keyboard-accessible where it makes sense, and free of business logic. Primitives: - Slider, SliderBank: value sliders with optional curve mapping; collapsible sections. - VirtualJoystick, XYPad: 2D pointer input, returning [0,1]^2. - Heatmap: NxN canvas rendering with three color modes (luminance/variance/divergence) and a configurable palette. - OutputDisplay: bar chart of N output values. - TrainingControls: thumbs-up/down/undo + train/randomise + status. - Drawer: portal'd slide-in panel from left or right; ESC + scrim close. - ControlAxis: compound-axis slider (Boldness/Memory/Precision shape) with active-preset hint, endpoint labels, double-tap-to-relink. - ProgressRing: SVG circular progress with optional inline label. - PillToggle: segmented radio control. - ParamEditor: min/max/curve/mute/pin/fixedValue editor for a single param. - JoyMap: zoom minimap with adaptive grid + zoom window, vanishing trail with Catmull-Rom spline + tap-to-return, dual concentric noise rings, region pin overlays, frozen overlay. - WeightHealth: 10-bin histogram + dead/saturating/healthy status glow. - GradientFlow: per-layer bar chart with vanishing/exploding/converged color coding. - LossPlot: log-scale line chart of training loss history. App.tsx now wires the /dev/primitives route to a lazy-loaded PrimitivesShowcase that renders all sixteen demos in a responsive grid. Lazy import keeps the home page bundle ~20 kB while the showcase ships its own ~42 kB chunk. Stream 8 of the rewrite (meml-911). --- playground/src/App.tsx | 24 +- .../src/dev/PrimitivesShowcase.module.css | 70 ++++ playground/src/dev/PrimitivesShowcase.tsx | 80 +++++ .../src/primitives/ControlAxis.demo.tsx | 43 +++ .../src/primitives/ControlAxis.module.css | 99 ++++++ playground/src/primitives/ControlAxis.tsx | 73 +++++ playground/src/primitives/Drawer.demo.tsx | 21 ++ playground/src/primitives/Drawer.module.css | 92 ++++++ playground/src/primitives/Drawer.tsx | 69 ++++ .../src/primitives/GradientFlow.demo.tsx | 36 ++ .../src/primitives/GradientFlow.module.css | 6 + playground/src/primitives/GradientFlow.tsx | 69 ++++ playground/src/primitives/Heatmap.demo.tsx | 46 +++ playground/src/primitives/Heatmap.module.css | 7 + playground/src/primitives/Heatmap.tsx | 138 ++++++++ playground/src/primitives/JoyMap.demo.tsx | 52 +++ playground/src/primitives/JoyMap.module.css | 8 + playground/src/primitives/JoyMap.tsx | 309 ++++++++++++++++++ playground/src/primitives/LossPlot.demo.tsx | 27 ++ playground/src/primitives/LossPlot.module.css | 6 + playground/src/primitives/LossPlot.tsx | 90 +++++ .../src/primitives/OutputDisplay.demo.tsx | 33 ++ .../src/primitives/OutputDisplay.module.css | 10 + playground/src/primitives/OutputDisplay.tsx | 67 ++++ .../src/primitives/ParamEditor.demo.tsx | 24 ++ .../src/primitives/ParamEditor.module.css | 66 ++++ playground/src/primitives/ParamEditor.tsx | 106 ++++++ playground/src/primitives/PillToggle.demo.tsx | 30 ++ .../src/primitives/PillToggle.module.css | 34 ++ playground/src/primitives/PillToggle.tsx | 41 +++ .../src/primitives/ProgressRing.demo.tsx | 20 ++ playground/src/primitives/ProgressRing.tsx | 73 +++++ playground/src/primitives/Slider.demo.tsx | 15 + playground/src/primitives/Slider.module.css | 96 ++++++ playground/src/primitives/Slider.tsx | 92 ++++++ playground/src/primitives/SliderBank.demo.tsx | 24 ++ .../src/primitives/SliderBank.module.css | 56 ++++ playground/src/primitives/SliderBank.tsx | 99 ++++++ .../src/primitives/TrainingControls.demo.tsx | 31 ++ .../primitives/TrainingControls.module.css | 85 +++++ .../src/primitives/TrainingControls.tsx | 81 +++++ .../src/primitives/VirtualJoystick.demo.tsx | 18 + .../src/primitives/VirtualJoystick.module.css | 71 ++++ playground/src/primitives/VirtualJoystick.tsx | 124 +++++++ .../src/primitives/WeightHealth.demo.tsx | 38 +++ .../src/primitives/WeightHealth.module.css | 37 +++ playground/src/primitives/WeightHealth.tsx | 75 +++++ playground/src/primitives/XYPad.demo.tsx | 14 + playground/src/primitives/XYPad.module.css | 63 ++++ playground/src/primitives/XYPad.tsx | 108 ++++++ playground/src/primitives/index.ts | 23 ++ 51 files changed, 3016 insertions(+), 3 deletions(-) create mode 100644 playground/src/dev/PrimitivesShowcase.module.css create mode 100644 playground/src/dev/PrimitivesShowcase.tsx create mode 100644 playground/src/primitives/ControlAxis.demo.tsx create mode 100644 playground/src/primitives/ControlAxis.module.css create mode 100644 playground/src/primitives/ControlAxis.tsx create mode 100644 playground/src/primitives/Drawer.demo.tsx create mode 100644 playground/src/primitives/Drawer.module.css create mode 100644 playground/src/primitives/Drawer.tsx create mode 100644 playground/src/primitives/GradientFlow.demo.tsx create mode 100644 playground/src/primitives/GradientFlow.module.css create mode 100644 playground/src/primitives/GradientFlow.tsx create mode 100644 playground/src/primitives/Heatmap.demo.tsx create mode 100644 playground/src/primitives/Heatmap.module.css create mode 100644 playground/src/primitives/Heatmap.tsx create mode 100644 playground/src/primitives/JoyMap.demo.tsx create mode 100644 playground/src/primitives/JoyMap.module.css create mode 100644 playground/src/primitives/JoyMap.tsx create mode 100644 playground/src/primitives/LossPlot.demo.tsx create mode 100644 playground/src/primitives/LossPlot.module.css create mode 100644 playground/src/primitives/LossPlot.tsx create mode 100644 playground/src/primitives/OutputDisplay.demo.tsx create mode 100644 playground/src/primitives/OutputDisplay.module.css create mode 100644 playground/src/primitives/OutputDisplay.tsx create mode 100644 playground/src/primitives/ParamEditor.demo.tsx create mode 100644 playground/src/primitives/ParamEditor.module.css create mode 100644 playground/src/primitives/ParamEditor.tsx create mode 100644 playground/src/primitives/PillToggle.demo.tsx create mode 100644 playground/src/primitives/PillToggle.module.css create mode 100644 playground/src/primitives/PillToggle.tsx create mode 100644 playground/src/primitives/ProgressRing.demo.tsx create mode 100644 playground/src/primitives/ProgressRing.tsx create mode 100644 playground/src/primitives/Slider.demo.tsx create mode 100644 playground/src/primitives/Slider.module.css create mode 100644 playground/src/primitives/Slider.tsx create mode 100644 playground/src/primitives/SliderBank.demo.tsx create mode 100644 playground/src/primitives/SliderBank.module.css create mode 100644 playground/src/primitives/SliderBank.tsx create mode 100644 playground/src/primitives/TrainingControls.demo.tsx create mode 100644 playground/src/primitives/TrainingControls.module.css create mode 100644 playground/src/primitives/TrainingControls.tsx create mode 100644 playground/src/primitives/VirtualJoystick.demo.tsx create mode 100644 playground/src/primitives/VirtualJoystick.module.css create mode 100644 playground/src/primitives/VirtualJoystick.tsx create mode 100644 playground/src/primitives/WeightHealth.demo.tsx create mode 100644 playground/src/primitives/WeightHealth.module.css create mode 100644 playground/src/primitives/WeightHealth.tsx create mode 100644 playground/src/primitives/XYPad.demo.tsx create mode 100644 playground/src/primitives/XYPad.module.css create mode 100644 playground/src/primitives/XYPad.tsx create mode 100644 playground/src/primitives/index.ts diff --git a/playground/src/App.tsx b/playground/src/App.tsx index cb35056..bce2877 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -1,13 +1,18 @@ -import { Component, createSignal, onCleanup, Show } from 'solid-js'; +import { Component, createSignal, lazy, onCleanup, Show } from 'solid-js'; import styles from './App.module.css'; -type Route = 'home' | 'unknown'; +type Route = 'home' | 'primitives' | 'unknown'; function parseRoute(path: string): Route { if (path === '' || path === '/' || path === '/index.html') return 'home'; + if (path === '/dev/primitives' || path === '/dev/primitives/') return 'primitives'; return 'unknown'; } +// Lazy-loaded so the home route doesn't pay the cost of loading every +// primitive demo. Stream 9/10 can grow the showcase freely. +const PrimitivesShowcase = lazy(() => import('./dev/PrimitivesShowcase')); + const App: Component = () => { const [route, setRoute] = createSignal(parseRoute(window.location.pathname)); @@ -34,12 +39,22 @@ const App: Component = () => { > home +
+ + +

404

@@ -61,8 +76,11 @@ const Home: Component = () => {

Interactive ML control of audio. SolidJS scaffold — modes coming online in stream 9.

+

- This is a fresh scaffold. Primitives, stores, ML, WASM, and audio engines are added in subsequent commits. + This is a fresh scaffold. ML, WASM, and audio engines are not yet wired up.

); diff --git a/playground/src/dev/PrimitivesShowcase.module.css b/playground/src/dev/PrimitivesShowcase.module.css new file mode 100644 index 0000000..25640d5 --- /dev/null +++ b/playground/src/dev/PrimitivesShowcase.module.css @@ -0,0 +1,70 @@ +.root { + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--sp-5); +} + +.header { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.header h1 { + margin: 0; + font-size: var(--fs-xl); + color: var(--accent); +} + +.lede { + color: var(--fg-mute); + margin: 0; + max-width: 720px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: var(--sp-4); +} + +.card { + background: var(--bg-1); + border: 1px solid var(--line); + border-radius: var(--r-3); + padding: var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-3); + min-height: 280px; +} + +.cardHead { + display: flex; + flex-direction: column; + gap: var(--sp-1); + border-bottom: 1px solid var(--line); + padding-bottom: var(--sp-2); +} + +.cardTitle { + margin: 0; + font-size: var(--fs-md); + color: var(--fg); +} + +.cardDesc { + margin: 0; + font-size: var(--fs-xs); + color: var(--fg-mute); +} + +.cardBody { + display: flex; + flex-direction: column; + align-items: stretch; + flex: 1; + justify-content: flex-start; +} diff --git a/playground/src/dev/PrimitivesShowcase.tsx b/playground/src/dev/PrimitivesShowcase.tsx new file mode 100644 index 0000000..07cac36 --- /dev/null +++ b/playground/src/dev/PrimitivesShowcase.tsx @@ -0,0 +1,80 @@ +/** + * Primitives showcase — Storybook-equivalent at /dev/primitives. + * + * Each section wraps one primitive's demo. Demos use realistic interactive + * state so a quick visual scan answers "does this thing actually work?". + */ + +import { Component, For, JSX } from 'solid-js'; +import styles from './PrimitivesShowcase.module.css'; + +import { SliderDemo } from '../primitives/Slider.demo'; +import { SliderBankDemo } from '../primitives/SliderBank.demo'; +import { VirtualJoystickDemo } from '../primitives/VirtualJoystick.demo'; +import { XYPadDemo } from '../primitives/XYPad.demo'; +import { HeatmapDemo } from '../primitives/Heatmap.demo'; +import { OutputDisplayDemo } from '../primitives/OutputDisplay.demo'; +import { TrainingControlsDemo } from '../primitives/TrainingControls.demo'; +import { DrawerDemo } from '../primitives/Drawer.demo'; +import { ControlAxisDemo } from '../primitives/ControlAxis.demo'; +import { ProgressRingDemo } from '../primitives/ProgressRing.demo'; +import { PillToggleDemo } from '../primitives/PillToggle.demo'; +import { ParamEditorDemo } from '../primitives/ParamEditor.demo'; +import { JoyMapDemo } from '../primitives/JoyMap.demo'; +import { WeightHealthDemo } from '../primitives/WeightHealth.demo'; +import { GradientFlowDemo } from '../primitives/GradientFlow.demo'; +import { LossPlotDemo } from '../primitives/LossPlot.demo'; + +interface Section { + name: string; + description: string; + Demo: Component; +} + +const SECTIONS: ReadonlyArray
= [ + { name: 'Slider', description: 'Single-value slider with optional curve mapping.', Demo: SliderDemo }, + { name: 'SliderBank', description: 'Vertical stack with collapsible sections.', Demo: SliderBankDemo }, + { name: 'VirtualJoystick', description: 'Touch + pointer 2D input, returns [0,1]^2.', Demo: VirtualJoystickDemo }, + { name: 'XYPad', description: 'Rectangular 2D input pad.', Demo: XYPadDemo }, + { name: 'Heatmap', description: 'NxN colored grid, three color modes.', Demo: HeatmapDemo }, + { name: 'OutputDisplay', description: 'Bar chart of N output values.', Demo: OutputDisplayDemo }, + { name: 'TrainingControls', description: 'RL feedback + status panel.', Demo: TrainingControlsDemo }, + { name: 'Drawer', description: 'Slide-in panel from left/right.', Demo: DrawerDemo }, + { name: 'ControlAxis', description: 'Compound axis slider (Boldness/Memory/Precision shape).', Demo: ControlAxisDemo }, + { name: 'ProgressRing', description: 'Circular progress indicator.', Demo: ProgressRingDemo }, + { name: 'PillToggle', description: 'Segmented control.', Demo: PillToggleDemo }, + { name: 'ParamEditor', description: 'Range/curve/mute/pin for one parameter.', Demo: ParamEditorDemo }, + { name: 'JoyMap', description: 'Zoom minimap with trail, noise rings, region pins.', Demo: JoyMapDemo }, + { name: 'WeightHealth', description: 'Histogram with status glow.', Demo: WeightHealthDemo }, + { name: 'GradientFlow', description: 'Per-layer gradient bars.', Demo: GradientFlowDemo }, + { name: 'LossPlot', description: 'Training-loss line chart over time.', Demo: LossPlotDemo }, +]; + +const PrimitivesShowcase: Component = () => { + return ( +
+
+

Primitive Library

+

+ Each card is a live demo. Use this page as a sanity-check during the rewrite. + Modes (stream 9) and feature parity (stream 10) compose these primitives. +

+
+
+ {(section) => ( +
+
+

{section.name}

+

{section.description}

+
+
+ {section.Demo({}) as unknown as JSX.Element} +
+
+ )}
+
+
+ ); +}; + +export default PrimitivesShowcase; diff --git a/playground/src/primitives/ControlAxis.demo.tsx b/playground/src/primitives/ControlAxis.demo.tsx new file mode 100644 index 0000000..ab13e29 --- /dev/null +++ b/playground/src/primitives/ControlAxis.demo.tsx @@ -0,0 +1,43 @@ +import { Component, createSignal } from 'solid-js'; +import { ControlAxis } from './ControlAxis'; + +export const ControlAxisDemo: Component = () => { + const [boldness, setBoldness] = createSignal(0.5); + const [memory, setMemory] = createSignal(0.7); + const [precision, setPrecision] = createSignal(0.3); + const [preset, setPreset] = createSignal('default'); + + const onAny = () => setPreset(null); + + return ( +
+ { setBoldness(v); onAny(); }} + preset={preset} + endpoints={['Caution', 'Bold']} + accentColor="var(--accent)" + onDoubleTap={() => setBoldness(0.5)} + /> + { setMemory(v); onAny(); }} + preset={preset} + endpoints={['Amnesia', 'Elephant']} + accentColor="var(--accent-2)" + onDoubleTap={() => setMemory(0.5)} + /> + { setPrecision(v); onAny(); }} + preset={preset} + endpoints={['Raw', 'Precise']} + accentColor="var(--good)" + onDoubleTap={() => setPrecision(0.3)} + /> +
+ ); +}; diff --git a/playground/src/primitives/ControlAxis.module.css b/playground/src/primitives/ControlAxis.module.css new file mode 100644 index 0000000..34f4c1e --- /dev/null +++ b/playground/src/primitives/ControlAxis.module.css @@ -0,0 +1,99 @@ +.axis { + display: flex; + flex-direction: column; + gap: var(--sp-1); + background: var(--bg-1); + border: 1px solid var(--line); + border-radius: var(--r-2); + padding: var(--sp-2) var(--sp-3); + --axis-accent: var(--accent); +} + +.head { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--fs-sm); +} + +.label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg); + flex: 1; +} + +.preset { + color: var(--axis-accent); + font-size: var(--fs-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.value { + font-variant-numeric: tabular-nums; + color: var(--fg-mute); + font-size: var(--fs-xs); + min-width: 4ch; + text-align: right; +} + +.input { + appearance: none; + -webkit-appearance: none; + width: 100%; + height: 24px; + background: transparent; + margin: 0; + cursor: pointer; +} + +.input::-webkit-slider-runnable-track { + height: 6px; + border-radius: 999px; + background: var(--bg-3); +} + +.input::-moz-range-track { + height: 6px; + border-radius: 999px; + background: var(--bg-3); +} + +.input::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--axis-accent); + margin-top: -6px; + box-shadow: 0 0 10px var(--axis-accent); + cursor: pointer; +} + +.input::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--axis-accent); + border: none; + box-shadow: 0 0 10px var(--axis-accent); +} + +.input:focus { outline: none; } + +.endpoints { + display: flex; + justify-content: space-between; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-dim); +} + +.disabled { + opacity: 0.5; + pointer-events: none; +} diff --git a/playground/src/primitives/ControlAxis.tsx b/playground/src/primitives/ControlAxis.tsx new file mode 100644 index 0000000..ba718f0 --- /dev/null +++ b/playground/src/primitives/ControlAxis.tsx @@ -0,0 +1,73 @@ +import { Component, Show } from 'solid-js'; +import styles from './ControlAxis.module.css'; + +export interface ControlAxisProps { + label: string; + value: () => number; + onChange: (v: number) => void; + /** Active control preset id (rendered as subtitle when set). */ + preset?: () => string | null; + /** Endpoint labels (left, right). E.g. ['Caution', 'Bold']. */ + endpoints?: readonly [string, string]; + /** Called when user double-taps the axis (re-link offsets). */ + onDoubleTap?: () => void; + /** Disabled state. */ + disabled?: boolean; + /** Visual color hint (defaults to --accent). */ + accentColor?: string; +} + +export const ControlAxis: Component = (props) => { + let lastTap = 0; + + const onPointerDown = () => { + const now = performance.now(); + if (now - lastTap < 350 && props.onDoubleTap) { + props.onDoubleTap(); + lastTap = 0; + } else { + lastTap = now; + } + }; + + const onInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => { + const v = parseFloat(e.currentTarget.value); + if (!Number.isNaN(v)) props.onChange(Math.max(0, Math.min(1, v))); + }; + + const accent = () => props.accentColor ?? 'var(--accent)'; + + return ( +
+
+ {props.label} + + {props.preset!()} + + {props.value().toFixed(2)} +
+ + +
+ {props.endpoints![0]} + {props.endpoints![1]} +
+
+
+ ); +}; diff --git a/playground/src/primitives/Drawer.demo.tsx b/playground/src/primitives/Drawer.demo.tsx new file mode 100644 index 0000000..3ac4b9c --- /dev/null +++ b/playground/src/primitives/Drawer.demo.tsx @@ -0,0 +1,21 @@ +import { Component, createSignal } from 'solid-js'; +import { Drawer } from './Drawer'; + +export const DrawerDemo: Component = () => { + const [openR, setOpenR] = createSignal(false); + const [openL, setOpenL] = createSignal(false); + return ( +
+ + + setOpenL(false)} side="left" title="Left drawer"> +

Slides in from the left.

+

Press Escape, click outside, or click × to close.

+
+ setOpenR(false)} side="right" title="Right drawer"> +

Slides in from the right.

+

Mode-specific drawers will use this primitive.

+
+
+ ); +}; diff --git a/playground/src/primitives/Drawer.module.css b/playground/src/primitives/Drawer.module.css new file mode 100644 index 0000000..f138d53 --- /dev/null +++ b/playground/src/primitives/Drawer.module.css @@ -0,0 +1,92 @@ +.root { + position: fixed; + inset: 0; + z-index: var(--z-drawer); + pointer-events: none; +} + +.scrim { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); + pointer-events: auto; + animation: fadeIn var(--dur-fast) var(--ease); +} + +.panel { + position: absolute; + top: 0; + bottom: 0; + background: var(--bg-1); + border-left: 1px solid var(--line); + pointer-events: auto; + display: flex; + flex-direction: column; + box-shadow: 0 0 24px rgba(0, 0, 0, 0.5); + animation: slideIn var(--dur-med) var(--ease); +} + +.left .panel { + left: 0; + border-right: 1px solid var(--line); + border-left: 0; + animation-name: slideInLeft; +} + +.right .panel { + right: 0; +} + +.header { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--line); + flex: 0 0 auto; +} + +.title { + flex: 1; + font-size: var(--fs-md); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-mute); +} + +.close { + background: transparent; + border: 0; + font-size: 22px; + color: var(--fg-mute); + width: 32px; + height: 32px; + border-radius: var(--r-1); + padding: 0; +} + +.close:hover { + background: var(--bg-2); + color: var(--fg); +} + +.body { + flex: 1; + overflow: auto; + padding: var(--sp-3) var(--sp-4); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +@keyframes slideInLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} diff --git a/playground/src/primitives/Drawer.tsx b/playground/src/primitives/Drawer.tsx new file mode 100644 index 0000000..445e02a --- /dev/null +++ b/playground/src/primitives/Drawer.tsx @@ -0,0 +1,69 @@ +import { Component, JSX, onCleanup, onMount, Show } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import styles from './Drawer.module.css'; + +export interface DrawerProps { + open: boolean; + onClose: () => void; + side: 'left' | 'right'; + title?: string; + children: JSX.Element; + /** Optional width in px (default 360). */ + width?: number; + /** Set to false to disable click-outside / Escape close behaviour. */ + dismissable?: boolean; +} + +export const Drawer: Component = (props) => { + const onKey = (e: KeyboardEvent) => { + if (!props.open) return; + if (props.dismissable === false) return; + if (e.key === 'Escape') { + e.stopPropagation(); + props.onClose(); + } + }; + + onMount(() => { + document.addEventListener('keydown', onKey); + }); + onCleanup(() => { + document.removeEventListener('keydown', onKey); + }); + + return ( + + +