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).
This commit is contained in:
w1n5t0n 2026-04-29 15:38:55 +03:00
parent add05ea554
commit c3fb63a113
51 changed files with 3016 additions and 3 deletions

View file

@ -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'; import styles from './App.module.css';
type Route = 'home' | 'unknown'; type Route = 'home' | 'primitives' | 'unknown';
function parseRoute(path: string): Route { function parseRoute(path: string): Route {
if (path === '' || path === '/' || path === '/index.html') return 'home'; if (path === '' || path === '/' || path === '/index.html') return 'home';
if (path === '/dev/primitives' || path === '/dev/primitives/') return 'primitives';
return 'unknown'; 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 App: Component = () => {
const [route, setRoute] = createSignal<Route>(parseRoute(window.location.pathname)); const [route, setRoute] = createSignal<Route>(parseRoute(window.location.pathname));
@ -34,12 +39,22 @@ const App: Component = () => {
> >
home home
</button> </button>
<button
type="button"
class={route() === 'primitives' ? styles.active : ''}
onClick={() => navigate('/dev/primitives')}
>
/dev/primitives
</button>
</nav> </nav>
</header> </header>
<main class={styles.main}> <main class={styles.main}>
<Show when={route() === 'home'}> <Show when={route() === 'home'}>
<Home /> <Home />
</Show> </Show>
<Show when={route() === 'primitives'}>
<PrimitivesShowcase />
</Show>
<Show when={route() === 'unknown'}> <Show when={route() === 'unknown'}>
<div class={styles.notFound}> <div class={styles.notFound}>
<h1>404</h1> <h1>404</h1>
@ -61,8 +76,11 @@ const Home: Component = () => {
<p class={styles.tagline}> <p class={styles.tagline}>
Interactive ML control of audio. SolidJS scaffold modes coming online in stream 9. Interactive ML control of audio. SolidJS scaffold modes coming online in stream 9.
</p> </p>
<ul class={styles.linkList}>
<li><a href="/dev/primitives" onClick={(e) => { e.preventDefault(); window.history.pushState({}, '', '/dev/primitives'); window.dispatchEvent(new PopStateEvent('popstate')); }}>Primitives showcase</a> UI building blocks</li>
</ul>
<p class={styles.note}> <p class={styles.note}>
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.
</p> </p>
</div> </div>
); );

View file

@ -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;
}

View file

@ -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<Section> = [
{ 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 (
<div class={styles.root}>
<header class={styles.header}>
<h1>Primitive Library</h1>
<p class={styles.lede}>
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.
</p>
</header>
<div class={styles.grid}>
<For each={SECTIONS}>{(section) => (
<article class={styles.card}>
<header class={styles.cardHead}>
<h2 class={styles.cardTitle}>{section.name}</h2>
<p class={styles.cardDesc}>{section.description}</p>
</header>
<div class={styles.cardBody}>
{section.Demo({}) as unknown as JSX.Element}
</div>
</article>
)}</For>
</div>
</div>
);
};
export default PrimitivesShowcase;

View file

@ -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<string | null>('default');
const onAny = () => setPreset(null);
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px', 'min-width': '320px' }}>
<ControlAxis
label="Boldness"
value={boldness}
onChange={(v) => { setBoldness(v); onAny(); }}
preset={preset}
endpoints={['Caution', 'Bold']}
accentColor="var(--accent)"
onDoubleTap={() => setBoldness(0.5)}
/>
<ControlAxis
label="Memory"
value={memory}
onChange={(v) => { setMemory(v); onAny(); }}
preset={preset}
endpoints={['Amnesia', 'Elephant']}
accentColor="var(--accent-2)"
onDoubleTap={() => setMemory(0.5)}
/>
<ControlAxis
label="Precision"
value={precision}
onChange={(v) => { setPrecision(v); onAny(); }}
preset={preset}
endpoints={['Raw', 'Precise']}
accentColor="var(--good)"
onDoubleTap={() => setPrecision(0.3)}
/>
</div>
);
};

View file

@ -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;
}

View file

@ -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<ControlAxisProps> = (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 (
<div
class={styles.axis}
classList={{ [styles.disabled]: !!props.disabled }}
style={{ '--axis-accent': accent() }}
>
<div class={styles.head}>
<span class={styles.label}>{props.label}</span>
<Show when={props.preset?.()}>
<span class={styles.preset}>{props.preset!()}</span>
</Show>
<span class={styles.value}>{props.value().toFixed(2)}</span>
</div>
<input
class={styles.input}
type="range"
min={0}
max={1}
step={0.001}
value={props.value()}
onInput={onInput}
onPointerDown={onPointerDown}
disabled={props.disabled}
aria-label={`${props.label} control axis`}
/>
<Show when={props.endpoints}>
<div class={styles.endpoints}>
<span>{props.endpoints![0]}</span>
<span>{props.endpoints![1]}</span>
</div>
</Show>
</div>
);
};

View file

@ -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 (
<div style={{ display: 'flex', gap: '8px', 'flex-wrap': 'wrap' }}>
<button type="button" onClick={() => setOpenL(true)}>Open Left</button>
<button type="button" onClick={() => setOpenR(true)}>Open Right</button>
<Drawer open={openL()} onClose={() => setOpenL(false)} side="left" title="Left drawer">
<p>Slides in from the left.</p>
<p>Press Escape, click outside, or click × to close.</p>
</Drawer>
<Drawer open={openR()} onClose={() => setOpenR(false)} side="right" title="Right drawer">
<p>Slides in from the right.</p>
<p>Mode-specific drawers will use this primitive.</p>
</Drawer>
</div>
);
};

View file

@ -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); }
}

View file

@ -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<DrawerProps> = (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 (
<Show when={props.open}>
<Portal>
<div
class={styles.root}
classList={{ [styles.left]: props.side === 'left', [styles.right]: props.side === 'right' }}
role="dialog"
aria-label={props.title ?? 'drawer'}
aria-modal="false"
>
<div
class={styles.scrim}
onClick={() => {
if (props.dismissable !== false) props.onClose();
}}
/>
<aside
class={styles.panel}
style={{ width: `${props.width ?? 360}px` }}
>
<header class={styles.header}>
<span class={styles.title}>{props.title ?? ''}</span>
<button
type="button"
class={styles.close}
onClick={props.onClose}
aria-label="Close drawer"
>×</button>
</header>
<div class={styles.body}>{props.children}</div>
</aside>
</div>
</Portal>
</Show>
);
};

View file

@ -0,0 +1,36 @@
import { Component, createMemo, createSignal } from 'solid-js';
import { GradientFlow, type GradientStatus } from './GradientFlow';
import { PillToggle } from './PillToggle';
export const GradientFlowDemo: Component = () => {
const [pattern, setPattern] = createSignal<'healthy' | 'vanishing' | 'exploding'>('healthy');
const norms = createMemo<number[]>(() => {
const p = pattern();
if (p === 'vanishing') return [1.0, 0.4, 0.15, 0.04];
if (p === 'exploding') return [0.4, 0.8, 1.6, 3.2];
return [0.6, 0.55, 0.5, 0.5];
});
const status = createMemo<GradientStatus[]>(() => {
const p = pattern();
if (p === 'vanishing') return ['healthy', 'vanishing', 'vanishing', 'vanishing'];
if (p === 'exploding') return ['healthy', 'healthy', 'exploding', 'exploding'];
return ['healthy', 'healthy', 'healthy', 'healthy'];
});
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' }}>
<GradientFlow layerNorms={norms} status={status} />
<PillToggle
options={[
{ value: 'healthy', label: 'Healthy' },
{ value: 'vanishing', label: 'Vanishing' },
{ value: 'exploding', label: 'Exploding' },
]}
value={pattern}
onChange={(v) => setPattern(v as 'healthy' | 'vanishing' | 'exploding')}
/>
</div>
);
};

View file

@ -0,0 +1,6 @@
.canvas {
display: block;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
}

View file

@ -0,0 +1,69 @@
import { Component, createEffect } from 'solid-js';
import styles from './GradientFlow.module.css';
export type GradientStatus = 'vanishing' | 'exploding' | 'converged' | 'healthy';
export interface GradientFlowProps {
/** Per-layer L2 norms of weight delta. */
layerNorms: () => ReadonlyArray<number>;
/** Per-layer status. Length must match layerNorms(). */
status: () => ReadonlyArray<GradientStatus>;
width?: number;
height?: number;
ariaLabel?: string;
}
const STATUS_COLOR: Record<GradientStatus, string> = {
vanishing: '#5b9eef',
exploding: '#ef5b5b',
converged: '#9a9a9a',
healthy: '#6bc26b',
};
export const GradientFlow: Component<GradientFlowProps> = (props) => {
let canvasEl: HTMLCanvasElement | undefined;
createEffect(() => {
if (!canvasEl) return;
const dpr = window.devicePixelRatio || 1;
const w = (props.width ?? 240) * dpr;
const h = (props.height ?? 80) * dpr;
if (canvasEl.width !== w || canvasEl.height !== h) {
canvasEl.width = w;
canvasEl.height = h;
}
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
const norms = props.layerNorms();
const status = props.status();
if (norms.length === 0) return;
let max = 0;
for (const n of norms) if (n > max) max = n;
if (max <= 0) max = 1;
const barW = w / norms.length;
for (let i = 0; i < norms.length; i++) {
const bh = (norms[i]! / max) * (h - 14 * dpr);
const s = status[i] ?? 'healthy';
ctx.fillStyle = STATUS_COLOR[s];
ctx.fillRect(i * barW + 2, h - bh - 2, barW - 4, bh);
ctx.fillStyle = '#5a5a5a';
ctx.font = `${10 * dpr}px var(--font-mono)`;
ctx.textAlign = 'center';
ctx.fillText(`L${i}`, i * barW + barW / 2, h - 2);
}
});
return (
<canvas
ref={canvasEl}
class={styles.canvas}
style={{ width: `${props.width ?? 240}px`, height: `${props.height ?? 80}px` }}
role="img"
aria-label={props.ariaLabel ?? 'Gradient flow per layer'}
/>
);
};

View file

@ -0,0 +1,46 @@
import { Component, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { Heatmap, type HeatmapColorMode } from './Heatmap';
import { PillToggle } from './PillToggle';
const N = 16;
function generateField(seed: number): Float32Array {
const arr = new Float32Array(N * N);
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const fx = (x - N / 2) / (N / 2);
const fy = (y - N / 2) / (N / 2);
const r = Math.sqrt(fx * fx + fy * fy);
const v = 0.5 + 0.5 * Math.sin(r * 3 + seed * 0.5) * Math.cos(fx * 4 + seed);
arr[y * N + x] = Math.max(0, Math.min(1, v));
}
}
return arr;
}
export const HeatmapDemo: Component = () => {
const [mode, setMode] = createSignal<HeatmapColorMode>('luminance');
const [tick, setTick] = createSignal(0);
let timer: ReturnType<typeof setInterval>;
onMount(() => {
timer = setInterval(() => setTick((t) => t + 1), 200);
});
onCleanup(() => clearInterval(timer));
const samples = createMemo(() => generateField(tick()));
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px', 'align-items': 'center' }}>
<Heatmap samples={samples} resolution={N} colorMode={mode()} size={240} showGrid={false} />
<PillToggle
options={[
{ value: 'luminance', label: 'Luminance' },
{ value: 'variance', label: 'Variance' },
{ value: 'divergence', label: 'Divergence' },
]}
value={mode}
onChange={(v) => setMode(v as HeatmapColorMode)}
/>
</div>
);
};

View file

@ -0,0 +1,7 @@
.canvas {
display: block;
border: 1px solid var(--line);
border-radius: var(--r-2);
background: var(--bg-1);
image-rendering: pixelated;
}

View file

@ -0,0 +1,138 @@
import { Component, createEffect, onCleanup } from 'solid-js';
import styles from './Heatmap.module.css';
export type HeatmapColorMode = 'luminance' | 'variance' | 'divergence';
export interface HeatmapProps {
/**
* Reactive accessor returning a Float32Array. The array layout depends
* on the color mode:
* - luminance: N*N values (mean output magnitude per cell)
* - variance: N*N values (variance per cell)
* - divergence: N*N*S values where S is sample length per cell;
* only the first N*N are used as a scalar (precomputed). For
* arbitrary modes the caller should pre-reduce to N*N.
*
* If `samples` is empty the canvas is cleared.
*/
samples: () => Float32Array;
/** Number of cells per side (e.g. 16 → 16×16). */
resolution: number;
/** Color mode (affects palette interpretation). */
colorMode: HeatmapColorMode;
/** Optional palette as an array of CSS color stops. Default: dark→amber→white. */
palette?: ReadonlyArray<string>;
/** Pixel size of the canvas. Default 240. */
size?: number;
/** When true, draw cell borders for clarity. */
showGrid?: boolean;
ariaLabel?: string;
}
const DEFAULT_PALETTE = ['#1a0033', '#5a1f5a', '#a64f30', '#ff8030', '#ffe0a8', '#ffffff'];
function paletteRGB(palette: ReadonlyArray<string>): Array<[number, number, number]> {
return palette.map((hex) => {
const m = hex.replace('#', '');
const r = parseInt(m.substring(0, 2), 16);
const g = parseInt(m.substring(2, 4), 16);
const b = parseInt(m.substring(4, 6), 16);
return [r, g, b];
});
}
function lerpRGB(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function sampleColor(palette: Array<[number, number, number]>, t: number): [number, number, number] {
if (palette.length === 0) return [0, 0, 0];
if (palette.length === 1) return palette[0]!;
const tt = Math.max(0, Math.min(1, t));
const seg = tt * (palette.length - 1);
const idx = Math.min(palette.length - 2, Math.floor(seg));
const frac = seg - idx;
return lerpRGB(palette[idx]!, palette[idx + 1]!, frac);
}
export const Heatmap: Component<HeatmapProps> = (props) => {
let canvasEl: HTMLCanvasElement | undefined;
createEffect(() => {
if (!canvasEl) return;
const data = props.samples();
const N = props.resolution;
const palette = paletteRGB(props.palette ?? DEFAULT_PALETTE);
const size = props.size ?? 240;
const dpr = window.devicePixelRatio || 1;
const pixelSize = size * dpr;
if (canvasEl.width !== pixelSize || canvasEl.height !== pixelSize) {
canvasEl.width = pixelSize;
canvasEl.height = pixelSize;
}
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, pixelSize, pixelSize);
if (data.length === 0 || N <= 0) return;
const cells = N * N;
if (data.length < cells) return;
// Find min/max for normalisation
let min = Infinity;
let max = -Infinity;
for (let i = 0; i < cells; i++) {
const v = data[i]!;
if (v < min) min = v;
if (v > max) max = v;
}
const range = max - min;
const cellPx = pixelSize / N;
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const v = data[y * N + x] ?? 0;
let t = range > 0 ? (v - min) / range : 0;
// Color mode interpretation
if (props.colorMode === 'variance') {
// Map variance such that low variance → dark, high → bright
t = Math.sqrt(t);
} else if (props.colorMode === 'divergence') {
// Divergence is signed-but-stored-as-magnitude — emphasize tails
t = t * t;
}
const [r, g, b] = sampleColor(palette, t);
ctx.fillStyle = `rgb(${r | 0}, ${g | 0}, ${b | 0})`;
ctx.fillRect(Math.floor(x * cellPx), Math.floor((N - 1 - y) * cellPx), Math.ceil(cellPx + 1), Math.ceil(cellPx + 1));
}
}
if (props.showGrid) {
ctx.strokeStyle = 'rgba(0,0,0,0.18)';
ctx.lineWidth = 1;
for (let i = 1; i < N; i++) {
const p = Math.floor(i * cellPx) + 0.5;
ctx.beginPath();
ctx.moveTo(p, 0);
ctx.lineTo(p, pixelSize);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, p);
ctx.lineTo(pixelSize, p);
ctx.stroke();
}
}
});
return (
<canvas
ref={canvasEl}
class={styles.canvas}
style={{ width: `${props.size ?? 240}px`, height: `${props.size ?? 240}px` }}
role="img"
aria-label={props.ariaLabel ?? `Heatmap (${props.colorMode})`}
/>
);
};

View file

@ -0,0 +1,52 @@
import { Component, createSignal, onCleanup, onMount } from 'solid-js';
import { JoyMap, type TrailPoint, type RegionPin } from './JoyMap';
import { Slider } from './Slider';
export const JoyMapDemo: Component = () => {
const [zoom, setZoom] = createSignal(0.5);
const [pos, setPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
const [trail, setTrail] = createSignal<TrailPoint[]>([]);
const [pins] = createSignal<RegionPin[]>([
{ id: 'p1', x: 0.6, y: 0.5, width: 0.18, height: 0.18, colorSlot: 0 },
{ id: 'p2', x: 0.18, y: 0.18, width: 0.12, height: 0.12, colorSlot: 1 },
]);
const [frozen, setFrozen] = createSignal(false);
// Simulated wandering position
let raf = 0;
let t0 = performance.now();
const animate = () => {
const t = (performance.now() - t0) / 1000;
const x = 0.5 + 0.4 * Math.sin(t * 0.4);
const y = 0.5 + 0.4 * Math.cos(t * 0.65);
setPos([x, y]);
setTrail((prev) => {
const next = [...prev, { x, y, t: performance.now() }];
// Cap trail length to avoid runaway growth
return next.slice(-200);
});
raf = requestAnimationFrame(animate);
};
onMount(() => { raf = requestAnimationFrame(animate); });
onCleanup(() => cancelAnimationFrame(raf));
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' }}>
<JoyMap
size={220}
zoom={zoom}
anchor={pos}
position={pos}
trail={trail}
regionPins={pins}
noiseRings={() => [0.18, 0.06]}
onTrailTap={(p) => setPos([p.x, p.y])}
frozen={frozen}
/>
<Slider value={zoom()} onChange={setZoom} min={0.01} max={1} label="zoom" />
<button type="button" onClick={() => setFrozen((f) => !f)}>
{frozen() ? 'Unfreeze' : 'Freeze'}
</button>
</div>
);
};

View file

@ -0,0 +1,8 @@
.canvas {
display: block;
border: 1px solid var(--line);
border-radius: var(--r-2);
background: var(--bg-1);
touch-action: none;
cursor: pointer;
}

View file

@ -0,0 +1,309 @@
import { Component, createEffect, onCleanup, onMount } from 'solid-js';
import styles from './JoyMap.module.css';
export interface TrailPoint {
x: number;
y: number;
/** Time in ms since some epoch (e.g. performance.now()). */
t: number;
}
export interface RegionPin {
id: string;
x: number; // [0,1]
y: number; // [0,1]
width: number; // [0,1]
height: number; // [0,1]
/** 0..4 mapping to --pin-1..--pin-5 tokens. */
colorSlot: number;
}
export interface JoyMapProps {
/** Current zoom level (0.011). 0.01 = frozen. */
zoom: () => number;
/** Anchor point for the zoom window (in [0,1]^2). */
anchor: () => readonly [number, number];
/** Live cursor position in [0,1]^2 (raw, not zoomed). */
position: () => readonly [number, number];
/** Recent trail points. */
trail: () => ReadonlyArray<TrailPoint>;
/** Active region pins. */
regionPins?: () => ReadonlyArray<RegionPin>;
/** Outer + inner ring radii [0..1] for noise visualization. */
noiseRings?: () => readonly [number, number] | null;
/** Called when user taps within the trail tap-hit radius of a trail point. */
onTrailTap?: (p: { x: number; y: number; t: number }) => void;
/** Called on long-press (for region pinning). */
onLongPress?: () => void;
/** Pixel size; canvas is square. */
size?: number;
/** Frozen overlay. */
frozen?: () => boolean;
ariaLabel?: string;
}
const TAP_HIT_RADIUS_PX = 12;
const TRAIL_DURATION_MS = 5000;
const TRAIL_MIN_WIDTH = 1.5;
const TRAIL_MAX_WIDTH = 4;
const LONG_PRESS_MS = 600;
const PIN_COLORS = [
'rgba(255, 106, 0, 0.25)',
'rgba(0, 204, 255, 0.25)',
'rgba(180, 100, 255, 0.25)',
'rgba(80, 200, 120, 0.25)',
'rgba(255, 200, 80, 0.25)',
];
const PIN_BORDERS = [
'rgba(255, 106, 0, 0.7)',
'rgba(0, 204, 255, 0.7)',
'rgba(180, 100, 255, 0.7)',
'rgba(80, 200, 120, 0.7)',
'rgba(255, 200, 80, 0.7)',
];
/** Centripetal Catmull-Rom spline segment. */
function drawCatmullRom(
ctx: CanvasRenderingContext2D,
pts: ReadonlyArray<{ x: number; y: number }>,
): void {
if (pts.length < 2) return;
ctx.beginPath();
ctx.moveTo(pts[0]!.x, pts[0]!.y);
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)]!;
const p1 = pts[i]!;
const p2 = pts[i + 1]!;
const p3 = pts[Math.min(pts.length - 1, i + 2)]!;
// 8-step segment subdivision
for (let s = 1; s <= 8; s++) {
const t = s / 8;
const t2 = t * t;
const t3 = t2 * t;
const x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
const y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
ctx.lineTo(x, y);
}
}
ctx.stroke();
}
export const JoyMap: Component<JoyMapProps> = (props) => {
let canvasEl: HTMLCanvasElement | undefined;
let frame = 0;
let pressTimer: ReturnType<typeof setTimeout> | null = null;
const size = () => props.size ?? 200;
const drawAdaptiveGrid = (ctx: CanvasRenderingContext2D, dim: number, zoom: number, anchor: readonly [number, number]) => {
// Adaptive subdivision: more lines as zoom decreases
const subdivisions = zoom <= 0.1 ? 16 : zoom <= 0.3 ? 8 : zoom <= 0.6 ? 4 : 2;
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
for (let i = 1; i < subdivisions; i++) {
const p = (i / subdivisions) * dim;
ctx.beginPath();
ctx.moveTo(p, 0);
ctx.lineTo(p, dim);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, p);
ctx.lineTo(dim, p);
ctx.stroke();
}
// Center crosshair
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.beginPath();
ctx.moveTo(0, dim / 2);
ctx.lineTo(dim, dim / 2);
ctx.moveTo(dim / 2, 0);
ctx.lineTo(dim / 2, dim);
ctx.stroke();
// Zoom window
const wx = anchor[0] - zoom * 0.5;
const wy = anchor[1] - zoom * 0.5;
ctx.strokeStyle = 'rgba(255, 106, 0, 0.7)';
ctx.lineWidth = 1;
ctx.strokeRect(wx * dim, (1 - wy - zoom) * dim, zoom * dim, zoom * dim);
};
const draw = () => {
if (!canvasEl) return;
const dpr = window.devicePixelRatio || 1;
const dim = size() * dpr;
if (canvasEl.width !== dim || canvasEl.height !== dim) {
canvasEl.width = dim;
canvasEl.height = dim;
}
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, dim, dim);
const zoom = Math.max(0.01, Math.min(1, props.zoom()));
const anchor = props.anchor();
const pos = props.position();
const trail = props.trail();
const pins = props.regionPins?.() ?? [];
// Background
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, dim, dim);
// Adaptive grid + zoom window
drawAdaptiveGrid(ctx, dim, zoom, anchor);
// Region pins
for (const pin of pins) {
const px = pin.x * dim;
const py = (1 - pin.y - pin.height) * dim;
const pw = pin.width * dim;
const ph = pin.height * dim;
const color = PIN_COLORS[pin.colorSlot % PIN_COLORS.length]!;
const border = PIN_BORDERS[pin.colorSlot % PIN_BORDERS.length]!;
ctx.fillStyle = color;
ctx.fillRect(px, py, pw, ph);
ctx.strokeStyle = border;
ctx.lineWidth = 2 * dpr;
ctx.strokeRect(px, py, pw, ph);
}
// Trail (vanishing, Catmull-Rom)
if (trail.length >= 2) {
const now = performance.now();
const trailWidth = TRAIL_MIN_WIDTH + (TRAIL_MAX_WIDTH - TRAIL_MIN_WIDTH) * (1 - zoom);
const points = trail
.filter((p) => now - p.t <= TRAIL_DURATION_MS)
.map((p) => ({ x: p.x * dim, y: (1 - p.y) * dim }));
if (points.length >= 2) {
ctx.lineWidth = trailWidth * dpr;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(255, 106, 0, 0.55)';
drawCatmullRom(ctx, points);
}
// Trail point dots (tap targets)
for (const p of trail) {
const age = now - p.t;
if (age > TRAIL_DURATION_MS) continue;
const fade = 1 - age / TRAIL_DURATION_MS;
ctx.fillStyle = `rgba(255, 106, 0, ${fade * 0.6})`;
const x = p.x * dim;
const y = (1 - p.y) * dim;
ctx.beginPath();
ctx.arc(x, y, 2 * dpr, 0, Math.PI * 2);
ctx.fill();
}
}
// Cursor
const cx = pos[0] * dim;
const cy = (1 - pos[1]) * dim;
ctx.fillStyle = '#00ccff';
ctx.beginPath();
ctx.arc(cx, cy, 4 * dpr, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 204, 255, 0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, 9 * dpr, 0, Math.PI * 2);
ctx.stroke();
// Noise rings
const rings = props.noiseRings?.();
if (rings) {
const [outer, inner] = rings;
ctx.strokeStyle = 'rgba(255, 200, 80, 0.4)';
ctx.beginPath();
ctx.arc(cx, cy, outer * dim, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(255, 200, 80, 0.6)';
ctx.beginPath();
ctx.arc(cx, cy, inner * dim, 0, Math.PI * 2);
ctx.stroke();
}
// Frozen overlay
if (props.frozen?.()) {
ctx.fillStyle = 'rgba(100, 180, 255, 0.15)';
ctx.fillRect(0, 0, dim, dim);
ctx.fillStyle = 'rgba(100, 180, 255, 0.85)';
ctx.font = `${12 * dpr}px var(--font-mono)`;
ctx.textAlign = 'center';
ctx.fillText('FROZEN', dim / 2, dim - 12 * dpr);
}
};
const animate = () => {
draw();
frame = requestAnimationFrame(animate);
};
onMount(() => {
frame = requestAnimationFrame(animate);
});
onCleanup(() => {
if (frame) cancelAnimationFrame(frame);
if (pressTimer) clearTimeout(pressTimer);
});
// Re-draw whenever props change (covers cases where rAF is throttled)
createEffect(() => {
props.zoom();
props.position();
props.trail();
draw();
});
const onPointerDown = (e: PointerEvent) => {
if (props.onLongPress) {
pressTimer = setTimeout(() => {
props.onLongPress?.();
pressTimer = null;
}, LONG_PRESS_MS);
}
// Tap-to-return: check trail
if (canvasEl && props.onTrailTap) {
const rect = canvasEl.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const trail = props.trail();
for (const p of trail) {
const tx = p.x * rect.width;
const ty = (1 - p.y) * rect.height;
const dx = tx - px;
const dy = ty - py;
if (dx * dx + dy * dy <= TAP_HIT_RADIUS_PX * TAP_HIT_RADIUS_PX) {
props.onTrailTap({ x: p.x, y: p.y, t: p.t });
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
return;
}
}
}
};
const onPointerUp = () => {
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
};
return (
<canvas
ref={canvasEl}
class={styles.canvas}
style={{ width: `${size()}px`, height: `${size()}px` }}
role="img"
aria-label={props.ariaLabel ?? 'Joy map'}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
);
};

View file

@ -0,0 +1,27 @@
import { Component, createSignal, onCleanup, onMount } from 'solid-js';
import { LossPlot } from './LossPlot';
export const LossPlotDemo: Component = () => {
const [history, setHistory] = createSignal<number[]>([]);
let timer: ReturnType<typeof setInterval>;
let i = 0;
onMount(() => {
timer = setInterval(() => {
i += 1;
const v = 0.5 * Math.exp(-i * 0.04) + 0.005 + 0.02 * Math.random();
setHistory((h) => {
const next = [...h, v];
return next.slice(-200);
});
}, 80);
});
onCleanup(() => clearInterval(timer));
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
<LossPlot history={history} log />
<button type="button" onClick={() => { setHistory([]); i = 0; }}>Reset</button>
</div>
);
};

View file

@ -0,0 +1,6 @@
.canvas {
display: block;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
}

View file

@ -0,0 +1,90 @@
import { Component, createEffect } from 'solid-js';
import styles from './LossPlot.module.css';
export interface LossPlotProps {
history: () => ReadonlyArray<number>;
/** Maximum points to display (older points are dropped). Default 200. */
maxPoints?: number;
width?: number;
height?: number;
/** Use logarithmic Y-axis. Default true. */
log?: boolean;
/** Color of plot line. */
color?: string;
ariaLabel?: string;
}
export const LossPlot: Component<LossPlotProps> = (props) => {
let canvasEl: HTMLCanvasElement | undefined;
createEffect(() => {
if (!canvasEl) return;
const dpr = window.devicePixelRatio || 1;
const w = (props.width ?? 320) * dpr;
const h = (props.height ?? 80) * dpr;
if (canvasEl.width !== w || canvasEl.height !== h) {
canvasEl.width = w;
canvasEl.height = h;
}
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
const all = props.history();
if (all.length === 0) return;
const max = props.maxPoints ?? 200;
const data = all.length > max ? all.slice(all.length - max) : all;
const log = props.log !== false;
const ys = data.map((v) => (log ? Math.log(Math.max(1e-10, v) + 1) : v));
let yMin = Infinity;
let yMax = -Infinity;
for (const y of ys) {
if (y < yMin) yMin = y;
if (y > yMax) yMax = y;
}
if (yMax === yMin) yMax = yMin + 1e-6;
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const y = (i / 4) * h;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// Plot line
ctx.strokeStyle = props.color ?? '#00ccff';
ctx.lineWidth = 1.5 * dpr;
ctx.beginPath();
for (let i = 0; i < ys.length; i++) {
const x = (i / Math.max(1, ys.length - 1)) * w;
const norm = (ys[i]! - yMin) / (yMax - yMin);
const y = h - norm * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Last value
const last = data[data.length - 1]!;
ctx.fillStyle = '#9a9a9a';
ctx.font = `${10 * dpr}px var(--font-mono)`;
ctx.textAlign = 'right';
ctx.fillText(last.toExponential(2), w - 4 * dpr, 12 * dpr);
});
return (
<canvas
ref={canvasEl}
class={styles.canvas}
style={{ width: `${props.width ?? 320}px`, height: `${props.height ?? 80}px` }}
role="img"
aria-label={props.ariaLabel ?? 'Training loss over time'}
/>
);
};

View file

@ -0,0 +1,33 @@
import { Component, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { OutputDisplay } from './OutputDisplay';
export const OutputDisplayDemo: Component = () => {
const [tick, setTick] = createSignal(0);
let raf = 0;
let last = performance.now();
const animate = (t: number) => {
if (t - last > 80) {
setTick((x) => x + 1);
last = t;
}
raf = requestAnimationFrame(animate);
};
onMount(() => { raf = requestAnimationFrame(animate); });
onCleanup(() => cancelAnimationFrame(raf));
const values = createMemo(() => {
const t = tick();
const arr = new Float32Array(126);
for (let i = 0; i < arr.length; i++) {
arr[i] = 0.5 + 0.5 * Math.sin(t * 0.05 + i * 0.21);
}
return arr;
});
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '6px' }}>
<OutputDisplay values={values} width={320} height={80} />
<OutputDisplay values={values} width={320} height={28} compact />
</div>
);
};

View file

@ -0,0 +1,10 @@
.canvas {
display: block;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
}
.compact {
border-radius: var(--r-1);
}

View file

@ -0,0 +1,67 @@
import { Component, createEffect } from 'solid-js';
import styles from './OutputDisplay.module.css';
export interface OutputDisplayProps {
values: () => Float32Array;
/** Optional labels per output. */
labels?: ReadonlyArray<string>;
/** Compact mode: thinner bars, no labels. */
compact?: boolean;
/** Pixel width. */
width?: number;
/** Pixel height. */
height?: number;
/** Optional bar color (default --accent). */
color?: string;
/** Show numeric label on hover (uses tooltip element). */
showHover?: boolean;
}
export const OutputDisplay: Component<OutputDisplayProps> = (props) => {
let canvasEl: HTMLCanvasElement | undefined;
const compact = () => props.compact ?? false;
createEffect(() => {
if (!canvasEl) return;
const values = props.values();
const dpr = window.devicePixelRatio || 1;
const w = (props.width ?? 320) * dpr;
const h = (props.height ?? (compact() ? 40 : 80)) * dpr;
if (canvasEl.width !== w || canvasEl.height !== h) {
canvasEl.width = w;
canvasEl.height = h;
}
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
if (values.length === 0) return;
const n = values.length;
const barW = w / n;
const color = props.color ?? '#ff6a00';
ctx.fillStyle = color;
for (let i = 0; i < n; i++) {
const v = Math.max(0, Math.min(1, values[i] ?? 0));
const bh = v * h;
ctx.fillRect(i * barW, h - bh, Math.max(1, barW - dpr), bh);
}
// Mid-line guide
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.beginPath();
ctx.moveTo(0, h * 0.5);
ctx.lineTo(w, h * 0.5);
ctx.stroke();
});
return (
<canvas
ref={canvasEl}
class={styles.canvas}
classList={{ [styles.compact]: compact() }}
style={{ width: `${props.width ?? 320}px`, height: `${props.height ?? (compact() ? 40 : 80)}px` }}
role="img"
aria-label={`Output bars (${props.values().length} channels)`}
/>
);
};

View file

@ -0,0 +1,24 @@
import { Component, createSignal } from 'solid-js';
import { ParamEditor, type ParamDef, type ParamOverride } from './ParamEditor';
const PARAM: ParamDef = {
name: 'osc1_freq',
label: 'Osc 1 Freq',
hardMin: 20,
hardMax: 8000,
default: 440,
curve: 'exp',
group: 'oscillators',
};
export const ParamEditorDemo: Component = () => {
const [override, setOverride] = createSignal<ParamOverride>({
min: 100,
max: 2000,
curve: 'exp',
muted: false,
pinned: false,
fixedValue: 440,
});
return <ParamEditor param={PARAM} override={override} onChange={setOverride} />;
};

View file

@ -0,0 +1,66 @@
.editor {
display: flex;
flex-direction: column;
gap: var(--sp-2);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
padding: var(--sp-3);
}
.compact {
padding: var(--sp-2);
gap: var(--sp-1);
}
.header {
display: flex;
align-items: baseline;
gap: var(--sp-2);
}
.name {
flex: 1;
font-size: var(--fs-sm);
color: var(--fg);
}
.id {
color: var(--fg-dim);
font-size: var(--fs-xs);
}
.toggles {
display: flex;
gap: var(--sp-2);
}
.fields {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.curveRow {
display: flex;
align-items: center;
gap: var(--sp-3);
font-size: var(--fs-xs);
}
.fieldLabel {
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 0 0 auto;
}
.select {
flex: 1;
background: var(--bg-2);
color: var(--fg);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-1) var(--sp-2);
font-size: var(--fs-sm);
}

View file

@ -0,0 +1,106 @@
import { Component } from 'solid-js';
import { Slider } from './Slider';
import { PillToggle } from './PillToggle';
import { CURVE_NAMES, type CurveName } from '../output/curves';
import styles from './ParamEditor.module.css';
/**
* A single parameter definition (subset of mode schema's param entry,
* shared between modes for the editor).
*/
export interface ParamDef {
name: string;
label: string;
/** Hard min/max for the parameter (used as override clamp limits). */
hardMin: number;
hardMax: number;
/** Authoring default. */
default: number;
/** Default curve from schema (overrides may change it). */
curve: CurveName;
group?: string;
}
/** Override applied between MLP output and parameter destination. */
export interface ParamOverride {
min: number;
max: number;
curve: CurveName;
curveParam?: number;
muted: boolean;
pinned: boolean;
fixedValue: number;
}
export interface ParamEditorProps {
param: ParamDef;
override: () => ParamOverride;
onChange: (next: ParamOverride) => void;
/** Compact rendering for inline use. */
compact?: boolean;
}
export const ParamEditor: Component<ParamEditorProps> = (props) => {
const update = (patch: Partial<ParamOverride>) => {
props.onChange({ ...props.override(), ...patch });
};
return (
<div class={styles.editor} classList={{ [styles.compact]: !!props.compact }}>
<header class={styles.header}>
<strong class={styles.name}>{props.param.label}</strong>
<span class={styles.id}>{props.param.name}</span>
</header>
<div class={styles.toggles}>
<PillToggle
options={[
{ value: 'live', label: 'Live' },
{ value: 'muted', label: 'Mute' },
{ value: 'pinned', label: 'Pin' },
]}
value={() => props.override().pinned ? 'pinned' : props.override().muted ? 'muted' : 'live'}
onChange={(v) => {
if (v === 'live') update({ muted: false, pinned: false });
else if (v === 'muted') update({ muted: true, pinned: false });
else update({ muted: false, pinned: true });
}}
ariaLabel="param mode"
/>
</div>
<div class={styles.fields}>
<Slider
label="min"
min={props.param.hardMin}
max={props.param.hardMax}
value={props.override().min}
onChange={(v) => update({ min: Math.min(v, props.override().max) })}
/>
<Slider
label="max"
min={props.param.hardMin}
max={props.param.hardMax}
value={props.override().max}
onChange={(v) => update({ max: Math.max(v, props.override().min) })}
/>
<label class={styles.curveRow}>
<span class={styles.fieldLabel}>curve</span>
<select
class={styles.select}
value={props.override().curve}
onChange={(e) => update({ curve: e.currentTarget.value as CurveName })}
>
{CURVE_NAMES.map((c) => <option value={c}>{c}</option>)}
</select>
</label>
<Slider
label="fixed value"
min={props.param.hardMin}
max={props.param.hardMax}
value={props.override().fixedValue}
onChange={(v) => update({ fixedValue: v })}
disabled={!props.override().muted}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,30 @@
import { Component, createSignal } from 'solid-js';
import { PillToggle } from './PillToggle';
export const PillToggleDemo: Component = () => {
const [mode, setMode] = createSignal('synth');
const [size, setSize] = createSignal('m');
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px', 'align-items': 'flex-start' }}>
<PillToggle
options={[
{ value: 'visual', label: 'Visual' },
{ value: 'synth', label: 'Synth' },
{ value: 'midi', label: 'MIDI CC' },
]}
value={mode}
onChange={setMode}
/>
<PillToggle
options={[
{ value: 's', label: 'S' },
{ value: 'm', label: 'M' },
{ value: 'l', label: 'L' },
{ value: 'xl', label: 'XL' },
]}
value={size}
onChange={setSize}
/>
</div>
);
};

View file

@ -0,0 +1,34 @@
.group {
display: inline-flex;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-pill);
padding: 2px;
gap: 2px;
}
.pill {
background: transparent;
border: 0;
border-radius: var(--r-pill);
padding: 6px 14px;
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-mute);
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.pill:hover:not(:disabled):not(.active) {
color: var(--fg);
}
.active {
background: var(--accent);
color: var(--bg);
}
.disabled {
opacity: 0.5;
pointer-events: none;
}

View file

@ -0,0 +1,41 @@
import { Component, For } from 'solid-js';
import styles from './PillToggle.module.css';
export interface PillToggleOption {
value: string;
label: string;
}
export interface PillToggleProps {
options: ReadonlyArray<PillToggleOption>;
value: () => string;
onChange: (v: string) => void;
/** Optional aria label for the group. */
ariaLabel?: string;
disabled?: boolean;
}
export const PillToggle: Component<PillToggleProps> = (props) => {
return (
<div
class={styles.group}
classList={{ [styles.disabled]: !!props.disabled }}
role="radiogroup"
aria-label={props.ariaLabel ?? 'segmented control'}
>
<For each={props.options}>{(opt) => (
<button
type="button"
class={styles.pill}
classList={{ [styles.active]: props.value() === opt.value }}
role="radio"
aria-checked={props.value() === opt.value}
onClick={() => !props.disabled && props.onChange(opt.value)}
disabled={props.disabled}
>
{opt.label}
</button>
)}</For>
</div>
);
};

View file

@ -0,0 +1,20 @@
import { Component, createSignal, onCleanup, onMount } from 'solid-js';
import { ProgressRing } from './ProgressRing';
export const ProgressRingDemo: Component = () => {
const [p, setP] = createSignal(0);
let timer: ReturnType<typeof setInterval>;
onMount(() => {
timer = setInterval(() => {
setP((q) => (q + 0.02) % 1);
}, 50);
});
onCleanup(() => clearInterval(timer));
return (
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
<ProgressRing progress={p} size={32} />
<ProgressRing progress={p} size={48} color="var(--accent-2)" />
<ProgressRing progress={p} size={64} color="var(--good)" showLabel />
</div>
);
};

View file

@ -0,0 +1,73 @@
import { Component, createMemo } from 'solid-js';
export interface ProgressRingProps {
/** 0..1 fraction. */
progress: () => number;
/** Diameter in px. Default 32. */
size?: number;
/** Stroke color. Default --accent. */
color?: string;
/** Stroke width. Default size/10. */
strokeWidth?: number;
/** Show numeric percent in middle. */
showLabel?: boolean;
ariaLabel?: string;
}
export const ProgressRing: Component<ProgressRingProps> = (props) => {
const size = () => props.size ?? 32;
const strokeWidth = () => props.strokeWidth ?? Math.max(2, Math.round(size() / 10));
const radius = () => (size() - strokeWidth()) / 2;
const circumference = () => 2 * Math.PI * radius();
const progress = () => Math.max(0, Math.min(1, props.progress()));
const offset = createMemo(() => circumference() * (1 - progress()));
const color = () => props.color ?? 'var(--accent)';
return (
<svg
width={size()}
height={size()}
viewBox={`0 0 ${size()} ${size()}`}
role="progressbar"
aria-valuenow={Math.round(progress() * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={props.ariaLabel ?? 'progress'}
>
<circle
cx={size() / 2}
cy={size() / 2}
r={radius()}
stroke="var(--bg-3)"
stroke-width={strokeWidth()}
fill="none"
/>
<circle
cx={size() / 2}
cy={size() / 2}
r={radius()}
stroke={color()}
stroke-width={strokeWidth()}
fill="none"
stroke-dasharray={`${circumference()}`}
stroke-dashoffset={`${offset()}`}
stroke-linecap="round"
transform={`rotate(-90 ${size() / 2} ${size() / 2})`}
style={{ transition: 'stroke-dashoffset 120ms linear' }}
/>
{props.showLabel && (
<text
x={size() / 2}
y={size() / 2}
dominant-baseline="central"
text-anchor="middle"
font-family="var(--font-mono)"
font-size={`${Math.round(size() / 3.5)}`}
fill="var(--fg-mute)"
>
{Math.round(progress() * 100)}
</text>
)}
</svg>
);
};

View file

@ -0,0 +1,15 @@
import { Component, createSignal } from 'solid-js';
import { Slider } from './Slider';
export const SliderDemo: Component = () => {
const [linear, setLinear] = createSignal(0.5);
const [exp, setExp] = createSignal(20);
const [neg, setNeg] = createSignal(0);
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px', 'min-width': '280px' }}>
<Slider value={linear()} onChange={setLinear} min={0} max={1} step={0.01} label="linear" />
<Slider value={exp()} onChange={setExp} min={1} max={1000} curve="exp" label="exp (1..1000)" unit="hz" />
<Slider value={neg()} onChange={setNeg} min={-50} max={50} step={1} label="bipolar (-50..50)" />
</div>
);
};

View file

@ -0,0 +1,96 @@
.wrap {
display: flex;
flex-direction: column;
gap: var(--sp-1);
font-size: var(--fs-sm);
color: var(--fg);
user-select: none;
}
.label {
color: var(--fg-mute);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.row {
display: flex;
gap: var(--sp-3);
align-items: center;
}
.input {
flex: 1;
appearance: none;
-webkit-appearance: none;
background: transparent;
height: 24px;
margin: 0;
cursor: pointer;
}
.input::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(to right, var(--accent) 0%, var(--accent) calc(var(--sp, 0) * 100%), var(--bg-3) 0%);
background-color: var(--bg-3);
}
.input::-moz-range-track {
height: 4px;
border-radius: 999px;
background: var(--bg-3);
}
.input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
margin-top: -6px;
box-shadow: 0 0 8px rgba(255, 106, 0, 0.4);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease);
}
.input::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
border: none;
box-shadow: 0 0 8px rgba(255, 106, 0, 0.4);
}
.input:hover::-webkit-slider-thumb {
transform: scale(1.15);
}
.input:focus {
outline: none;
}
.input:focus::-webkit-slider-thumb {
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.3);
}
.value {
font-variant-numeric: tabular-nums;
font-size: var(--fs-xs);
color: var(--fg-mute);
min-width: 4ch;
text-align: right;
}
.unit {
color: var(--fg-dim);
margin-left: 2px;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}

View file

@ -0,0 +1,92 @@
import { Component, createMemo, Show } from 'solid-js';
import { applyCurve, type CurveName } from '../output/curves';
import styles from './Slider.module.css';
export interface SliderProps {
value: number;
onChange: (v: number) => void;
min: number;
max: number;
step?: number;
/**
* If set, the slider position is normalised to [0,1], passed through the
* named curve, then mapped to [min, max]. Setter receives the **mapped**
* value. Reading `value` is also expected in the mapped range.
*/
curve?: CurveName;
curveParam?: number;
label?: string;
unit?: string;
disabled?: boolean;
ariaLabel?: string;
/** Optional precision for display (decimals). */
decimals?: number;
}
export const Slider: Component<SliderProps> = (props) => {
const min = () => props.min;
const max = () => props.max;
const step = () => props.step ?? (max() - min()) / 100;
const curve = () => props.curve ?? 'linear';
const curveParam = () => props.curveParam;
// Display value can use a smaller step granularity than the slider itself.
const display = createMemo(() => {
const v = props.value;
const d = props.decimals;
if (d === undefined) {
const range = max() - min();
const decimals = range >= 100 ? 0 : range >= 10 ? 1 : range >= 1 ? 2 : 3;
return v.toFixed(decimals);
}
return v.toFixed(d);
});
// Slider input is always [0,1] for curve mapping, mapped to [min,max].
const sliderPos = createMemo(() => {
const range = max() - min();
if (range <= 0) return 0;
const norm = (props.value - min()) / range;
if (curve() === 'linear') return norm;
// Inverse: we don't have inverse for all curves. For linear we round-trip
// exactly; for others we accept that the slider position is a *forward*
// mapping (snaps to nearest). Use raw normalised position for simplicity.
return norm;
});
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
const norm = parseFloat(e.currentTarget.value);
let value: number;
if (curve() === 'linear') {
value = min() + norm * (max() - min());
} else {
const curved = applyCurve(curve(), norm, curveParam());
value = min() + curved * (max() - min());
}
props.onChange(value);
};
return (
<label class={styles.wrap} classList={{ [styles.disabled]: !!props.disabled }}>
<Show when={props.label}>
<span class={styles.label}>{props.label}</span>
</Show>
<div class={styles.row}>
<input
class={styles.input}
type="range"
min={0}
max={1}
step={step() / Math.max(0.000001, max() - min())}
value={sliderPos()}
onInput={handleInput}
disabled={props.disabled}
aria-label={props.ariaLabel ?? props.label ?? 'slider'}
/>
<span class={styles.value}>
{display()}{props.unit ? <span class={styles.unit}>{props.unit}</span> : null}
</span>
</div>
</label>
);
};

View file

@ -0,0 +1,24 @@
import { Component, createSignal } from 'solid-js';
import { SliderBank, type SliderConfig } from './SliderBank';
const CFG: SliderConfig[] = [
{ id: 'cutoff', label: 'Cutoff', min: 20, max: 20000, curve: 'exp', section: 'Filter', unit: 'Hz' },
{ id: 'res', label: 'Resonance', min: 0, max: 1, step: 0.01, section: 'Filter' },
{ id: 'attack', label: 'Attack', min: 0, max: 5, step: 0.01, section: 'Envelope', unit: 's' },
{ id: 'decay', label: 'Decay', min: 0, max: 5, step: 0.01, section: 'Envelope', unit: 's' },
{ id: 'sustain', label: 'Sustain', min: 0, max: 1, step: 0.01, section: 'Envelope' },
{ id: 'release', label: 'Release', min: 0, max: 5, step: 0.01, section: 'Envelope', unit: 's' },
];
export const SliderBankDemo: Component = () => {
const [values, setValues] = createSignal<number[]>([8000, 0.3, 0.05, 0.4, 0.7, 1.0]);
return (
<SliderBank
sliders={CFG}
values={values}
onChange={(_id, v, i) => setValues((prev) => prev.map((x, j) => (j === i ? v : x)))}
title="Synth"
collapsedSections={[]}
/>
);
};

View file

@ -0,0 +1,56 @@
.bank {
display: flex;
flex-direction: column;
gap: var(--sp-3);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
padding: var(--sp-3);
}
.title {
font-size: var(--fs-md);
margin: 0 0 var(--sp-2) 0;
color: var(--fg);
}
.section {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) var(--sp-2);
background: var(--bg-2);
border: 1px solid var(--line);
text-align: left;
width: 100%;
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-mute);
}
.caret {
width: 1ch;
display: inline-block;
}
.sectionName {
flex: 1;
}
.sectionCount {
color: var(--fg-dim);
}
.list {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-1) 0;
}

View file

@ -0,0 +1,99 @@
import { Component, createSignal, For, Show } from 'solid-js';
import { Slider } from './Slider';
import type { CurveName } from '../output/curves';
import styles from './SliderBank.module.css';
export interface SliderConfig {
/** Stable id used as React-style key. */
id: string;
label: string;
min: number;
max: number;
step?: number;
curve?: CurveName;
curveParam?: number;
unit?: string;
/** Optional section header to start a new collapsible group. */
section?: string;
}
export interface SliderBankProps {
sliders: ReadonlyArray<SliderConfig>;
values: () => ReadonlyArray<number>;
onChange: (id: string, v: number, index: number) => void;
/** Title rendered at the top of the bank (above all sections). */
title?: string;
/** Default-collapsed sections by name. */
collapsedSections?: ReadonlyArray<string>;
}
interface Section {
name: string;
items: Array<{ cfg: SliderConfig; index: number }>;
}
function group(configs: ReadonlyArray<SliderConfig>): Section[] {
const sections: Section[] = [];
let current: Section | null = null;
configs.forEach((cfg, index) => {
const sectionName = cfg.section ?? '';
if (!current || current.name !== sectionName) {
current = { name: sectionName, items: [] };
sections.push(current);
}
current.items.push({ cfg, index });
});
return sections;
}
export const SliderBank: Component<SliderBankProps> = (props) => {
const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>(
Object.fromEntries((props.collapsedSections ?? []).map((s) => [s, true])),
);
const sections = () => group(props.sliders);
const toggle = (name: string) => {
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }));
};
return (
<div class={styles.bank}>
<Show when={props.title}>
<h3 class={styles.title}>{props.title}</h3>
</Show>
<For each={sections()}>{(section) => (
<section class={styles.section}>
<Show when={section.name}>
<button
type="button"
class={styles.sectionHeader}
onClick={() => toggle(section.name)}
aria-expanded={!collapsed()[section.name]}
>
<span class={styles.caret}>{collapsed()[section.name] ? '▸' : '▾'}</span>
<span class={styles.sectionName}>{section.name}</span>
<span class={styles.sectionCount}>{section.items.length}</span>
</button>
</Show>
<Show when={!collapsed()[section.name]}>
<div class={styles.list}>
<For each={section.items}>{({ cfg, index }) => (
<Slider
value={props.values()[index] ?? 0}
onChange={(v) => props.onChange(cfg.id, v, index)}
min={cfg.min}
max={cfg.max}
step={cfg.step}
curve={cfg.curve}
curveParam={cfg.curveParam}
label={cfg.label}
unit={cfg.unit}
/>
)}</For>
</div>
</Show>
</section>
)}</For>
</div>
);
};

View file

@ -0,0 +1,31 @@
import { Component, createSignal } from 'solid-js';
import { TrainingControls } from './TrainingControls';
export const TrainingControlsDemo: Component = () => {
const [count, setCount] = createSignal(3);
const [loss, setLoss] = createSignal<number | null>(0.0123);
const [busy, setBusy] = createSignal(false);
const [stack, setStack] = createSignal<number[]>([]);
const train = () => {
setBusy(true);
setTimeout(() => {
setLoss(Math.random() * 0.05);
setBusy(false);
}, 250);
};
return (
<TrainingControls
onTrain={train}
onRandomize={() => setLoss(0.5)}
onThumbsUp={() => { setStack((s) => [...s, count()]); setCount((c) => c + 1); train(); }}
onThumbsDown={() => { setStack((s) => [...s, count()]); setLoss((l) => (l ?? 0) * 1.4); }}
onUndo={() => { const prev = [...stack()]; const v = prev.pop(); if (v !== undefined) setCount(v); setStack(prev); }}
exampleCount={count}
lastLoss={loss}
busy={busy}
canUndo={() => stack().length > 0}
/>
);
};

View file

@ -0,0 +1,85 @@
.wrap {
display: flex;
flex-direction: column;
gap: var(--sp-2);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
padding: var(--sp-3);
font-size: var(--fs-sm);
}
.row {
display: flex;
gap: var(--sp-2);
}
.btn {
flex: 1;
height: 48px;
font-size: var(--fs-lg);
border-radius: var(--r-1);
font-weight: 600;
}
.thumbsUp {
background: rgba(107, 194, 107, 0.16);
border-color: rgba(107, 194, 107, 0.4);
color: var(--good);
}
.thumbsUp:hover:not(:disabled) {
background: rgba(107, 194, 107, 0.28);
}
.thumbsDown {
background: rgba(239, 91, 91, 0.16);
border-color: rgba(239, 91, 91, 0.4);
color: var(--bad);
}
.thumbsDown:hover:not(:disabled) {
background: rgba(239, 91, 91, 0.28);
}
.undo {
background: var(--bg-2);
color: var(--fg-mute);
}
.btnAlt {
flex: 1;
font-size: var(--fs-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.status {
display: flex;
gap: var(--sp-3);
padding-top: var(--sp-2);
border-top: 1px solid var(--line);
flex-wrap: wrap;
}
.statusItem {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--fs-xs);
}
.statusLabel {
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.statusValue {
color: var(--fg);
font-variant-numeric: tabular-nums;
}
.busy {
color: var(--accent-2);
align-self: center;
font-style: italic;
}

View file

@ -0,0 +1,81 @@
import { Component, Show } from 'solid-js';
import styles from './TrainingControls.module.css';
export interface TrainingControlsProps {
onTrain: () => void;
onRandomize: () => void;
onThumbsUp: () => void;
onThumbsDown: () => void;
onUndo: () => void;
exampleCount: () => number;
lastLoss: () => number | null;
/** Whether training is currently in progress (disables actions). */
busy?: () => boolean;
/** Whether undo stack is non-empty. */
canUndo?: () => boolean;
}
export const TrainingControls: Component<TrainingControlsProps> = (props) => {
const busy = () => props.busy?.() ?? false;
const canUndo = () => props.canUndo?.() ?? true;
return (
<div class={styles.wrap} role="toolbar" aria-label="Training controls">
<div class={styles.row}>
<button
type="button"
class={`${styles.btn} ${styles.thumbsDown}`}
onClick={props.onThumbsDown}
disabled={busy()}
aria-label="Thumbs down (explore)"
title="Thumbs down — explore (key: 2)"
></button>
<button
type="button"
class={`${styles.btn} ${styles.thumbsUp}`}
onClick={props.onThumbsUp}
disabled={busy()}
aria-label="Thumbs up (train)"
title="Thumbs up — train (key: 1)"
>+</button>
<button
type="button"
class={`${styles.btn} ${styles.undo}`}
onClick={props.onUndo}
disabled={busy() || !canUndo()}
aria-label="Undo"
title="Undo (key: Z)"
></button>
</div>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
onClick={props.onTrain}
disabled={busy()}
>Train</button>
<button
type="button"
class={styles.btnAlt}
onClick={props.onRandomize}
disabled={busy()}
>Randomise</button>
</div>
<div class={styles.status}>
<span class={styles.statusItem}>
<span class={styles.statusLabel}>examples</span>
<span class={styles.statusValue}>{props.exampleCount()}</span>
</span>
<Show when={props.lastLoss() !== null}>
<span class={styles.statusItem}>
<span class={styles.statusLabel}>loss</span>
<span class={styles.statusValue}>{props.lastLoss()!.toExponential(2)}</span>
</span>
</Show>
<Show when={busy()}>
<span class={`${styles.statusItem} ${styles.busy}`}>training</span>
</Show>
</div>
</div>
);
};

View file

@ -0,0 +1,18 @@
import { Component, createSignal } from 'solid-js';
import { VirtualJoystick } from './VirtualJoystick';
export const VirtualJoystickDemo: Component = () => {
const [pos, setPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'center' }}>
<VirtualJoystick
size={200}
position={pos}
onMove={(x, y) => setPos([x, y])}
/>
<code style={{ 'font-size': '11px', color: 'var(--fg-mute)' }}>
x={pos()[0].toFixed(3)} y={pos()[1].toFixed(3)}
</code>
</div>
);
};

View file

@ -0,0 +1,71 @@
.container {
position: relative;
background: var(--bg-1);
border-radius: 50%;
border: 1px solid var(--line);
touch-action: none;
cursor: grab;
outline: none;
user-select: none;
overflow: hidden;
}
.container:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.3);
}
.dragging {
cursor: grabbing;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.boundary {
position: absolute;
inset: 6%;
border-radius: 50%;
border: 1px dashed var(--line-strong);
pointer-events: none;
}
.crosshair {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.4;
}
.hLine, .vLine {
position: absolute;
background: var(--line-strong);
}
.hLine {
left: 0;
right: 0;
top: 50%;
height: 1px;
}
.vLine {
top: 0;
bottom: 0;
left: 50%;
width: 1px;
}
.knob {
position: absolute;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px rgba(255, 106, 0, 0.5);
pointer-events: none;
top: 0;
left: 0;
}

View file

@ -0,0 +1,124 @@
import { Component, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import styles from './VirtualJoystick.module.css';
export interface VirtualJoystickProps {
onMove: (x: number, y: number) => void;
/** Optional controlled position [x, y] in [0,1]. */
position?: () => readonly [number, number];
disabled?: boolean;
/** Pixel size of the square joystick area. */
size?: number;
/** Label for assistive tech. */
ariaLabel?: string;
/** Called on pointer up. */
onRelease?: () => void;
/** Called on pointer down (good for snapshots). */
onGrab?: () => void;
}
const DEFAULT_SIZE = 200;
export const VirtualJoystick: Component<VirtualJoystickProps> = (props) => {
const size = () => props.size ?? DEFAULT_SIZE;
const [internalPos, setInternalPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
const [dragging, setDragging] = createSignal(false);
let containerEl: HTMLDivElement | undefined;
const pos = () => props.position?.() ?? internalPos();
const updateFromEvent = (e: PointerEvent) => {
if (!containerEl) return;
const rect = containerEl.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1 - (e.clientY - rect.top) / rect.height; // y-axis up
const cx = Math.max(0, Math.min(1, x));
const cy = Math.max(0, Math.min(1, y));
// Constrain to circle
const dx = cx - 0.5;
const dy = cy - 0.5;
const dist = Math.sqrt(dx * dx + dy * dy);
let outX = cx;
let outY = cy;
if (dist > 0.5 && dist > 1e-12) {
outX = 0.5 + (dx / dist) * 0.5;
outY = 0.5 + (dy / dist) * 0.5;
}
if (props.position === undefined) setInternalPos([outX, outY]);
props.onMove(outX, outY);
};
const onPointerDown = (e: PointerEvent) => {
if (props.disabled) return;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
setDragging(true);
props.onGrab?.();
updateFromEvent(e);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging()) return;
updateFromEvent(e);
};
const onPointerUp = (e: PointerEvent) => {
if (!dragging()) return;
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
setDragging(false);
props.onRelease?.();
};
// Keyboard accessibility: arrow keys nudge.
const onKey = (e: KeyboardEvent) => {
if (props.disabled) return;
const step = e.shiftKey ? 0.05 : 0.01;
let [x, y] = pos();
let handled = true;
switch (e.key) {
case 'ArrowLeft': x -= step; break;
case 'ArrowRight': x += step; break;
case 'ArrowUp': y += step; break;
case 'ArrowDown': y -= step; break;
case 'Home': x = 0.5; y = 0.5; break;
default: handled = false;
}
if (handled) {
e.preventDefault();
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
if (props.position === undefined) setInternalPos([x, y]);
props.onMove(x, y);
}
};
const knobStyle = createMemo(() => {
const [x, y] = pos();
const px = x * size();
const py = (1 - y) * size();
return `transform: translate(${px}px, ${py}px) translate(-50%, -50%);`;
});
return (
<div
ref={containerEl}
class={styles.container}
classList={{ [styles.disabled]: !!props.disabled, [styles.dragging]: dragging() }}
style={{ width: `${size()}px`, height: `${size()}px` }}
role="application"
tabIndex={props.disabled ? -1 : 0}
aria-label={props.ariaLabel ?? 'virtual joystick'}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKey}
>
<div class={styles.boundary} />
<div class={styles.crosshair} aria-hidden="true">
<div class={styles.hLine} />
<div class={styles.vLine} />
</div>
<div class={styles.knob} style={knobStyle()} aria-hidden="true" />
</div>
);
};

View file

@ -0,0 +1,38 @@
import { Component, createMemo, createSignal } from 'solid-js';
import { WeightHealth, type WeightStatus } from './WeightHealth';
import { PillToggle } from './PillToggle';
export const WeightHealthDemo: Component = () => {
const [status, setStatus] = createSignal<WeightStatus>('healthy');
const histogram = createMemo<number[]>(() => {
const bins = new Array(10).fill(0);
const s = status();
if (s === 'dead') {
bins[0] = 80; bins[1] = 12; bins[2] = 4; bins[3] = 2;
} else if (s === 'saturating') {
bins[7] = 12; bins[8] = 24; bins[9] = 60;
} else {
// healthy bell-ish
for (let i = 0; i < 10; i++) {
bins[i] = 30 * Math.exp(-Math.pow((i - 4.5) / 2.5, 2));
}
}
return bins;
});
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' }}>
<WeightHealth histogram={histogram} status={status} />
<PillToggle
options={[
{ value: 'healthy', label: 'Healthy' },
{ value: 'dead', label: 'Dead' },
{ value: 'saturating', label: 'Saturating' },
]}
value={status}
onChange={(v) => setStatus(v as WeightStatus)}
/>
</div>
);
};

View file

@ -0,0 +1,37 @@
.wrap {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--sp-2);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
padding: var(--sp-2);
}
.canvas {
display: block;
background: var(--bg-2);
border-radius: var(--r-1);
}
.label {
align-self: center;
padding: 4px 12px;
font-size: var(--fs-xs);
letter-spacing: 0.1em;
border-radius: var(--r-pill);
background: var(--bg-2);
color: var(--fg);
font-weight: 600;
transition: box-shadow var(--dur-med) var(--ease);
}
.pulsing {
animation: pulse 1.6s var(--ease) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}

View file

@ -0,0 +1,75 @@
import { Component, createEffect, createMemo } from 'solid-js';
import styles from './WeightHealth.module.css';
export type WeightStatus = 'dead' | 'saturating' | 'healthy';
export interface WeightHealthProps {
/** 10-bin histogram of weight magnitudes. */
histogram: () => ReadonlyArray<number>;
status: () => WeightStatus;
/** Pixel width of histogram. */
width?: number;
height?: number;
ariaLabel?: string;
}
const STATUS_COLORS: Record<WeightStatus, { glow: string; text: string }> = {
dead: { glow: 'rgba(140, 140, 140, 0.45)', text: 'DEAD' },
saturating: { glow: 'rgba(245, 196, 94, 0.6)', text: 'SATURATING' },
healthy: { glow: 'rgba(107, 194, 107, 0.45)', text: 'HEALTHY' },
};
export const WeightHealth: Component<WeightHealthProps> = (props) => {
let canvasEl: HTMLCanvasElement | undefined;
const status = () => props.status();
createEffect(() => {
if (!canvasEl) return;
const dpr = window.devicePixelRatio || 1;
const w = (props.width ?? 200) * dpr;
const h = (props.height ?? 60) * dpr;
if (canvasEl.width !== w || canvasEl.height !== h) {
canvasEl.width = w;
canvasEl.height = h;
}
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
const bins = props.histogram();
if (bins.length === 0) return;
let maxBin = 0;
for (const b of bins) if (b > maxBin) maxBin = b;
if (maxBin <= 0) maxBin = 1;
const colors: Record<WeightStatus, string> = {
dead: 'rgba(140, 140, 140, 0.7)',
saturating: 'rgba(245, 196, 94, 0.85)',
healthy: 'rgba(107, 194, 107, 0.85)',
};
ctx.fillStyle = colors[status()];
const binW = w / bins.length;
for (let i = 0; i < bins.length; i++) {
const bh = (bins[i]! / maxBin) * h;
ctx.fillRect(i * binW + 1, h - bh, binW - 2, bh);
}
});
const glowStyle = createMemo(() => `box-shadow: 0 0 18px ${STATUS_COLORS[status()].glow};`);
return (
<div class={styles.wrap}>
<canvas
ref={canvasEl}
class={styles.canvas}
classList={{ [styles.pulsing]: status() === 'saturating' }}
style={{ width: `${props.width ?? 200}px`, height: `${props.height ?? 60}px` }}
role="img"
aria-label={props.ariaLabel ?? 'Weight health histogram'}
/>
<div class={styles.label} style={glowStyle()}>
{STATUS_COLORS[status()].text}
</div>
</div>
);
};

View file

@ -0,0 +1,14 @@
import { Component, createSignal } from 'solid-js';
import { XYPad } from './XYPad';
export const XYPadDemo: Component = () => {
const [pos, setPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
return (
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'center' }}>
<XYPad size={240} position={pos} onMove={(x, y) => setPos([x, y])} />
<code style={{ 'font-size': '11px', color: 'var(--fg-mute)' }}>
x={pos()[0].toFixed(3)} y={pos()[1].toFixed(3)}
</code>
</div>
);
};

View file

@ -0,0 +1,63 @@
.pad {
position: relative;
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
touch-action: none;
cursor: crosshair;
outline: none;
user-select: none;
overflow: hidden;
}
.pad:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.3);
}
.dragging {
cursor: crosshair;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.grid {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.5;
}
.gridH, .gridV {
position: absolute;
background: var(--line-strong);
}
.gridH {
left: 0;
right: 0;
top: 50%;
height: 1px;
}
.gridV {
top: 0;
bottom: 0;
left: 50%;
width: 1px;
}
.dot {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-2);
box-shadow: 0 0 10px rgba(0, 204, 255, 0.5);
pointer-events: none;
top: 0;
left: 0;
}

View file

@ -0,0 +1,108 @@
import { Component, createMemo, createSignal } from 'solid-js';
import styles from './XYPad.module.css';
export interface XYPadProps {
onMove: (x: number, y: number) => void;
position?: () => readonly [number, number];
disabled?: boolean;
/** Width and height in px. Default 240. */
size?: number;
/** Show internal grid lines. */
showGrid?: boolean;
ariaLabel?: string;
onRelease?: () => void;
onGrab?: () => void;
}
export const XYPad: Component<XYPadProps> = (props) => {
const size = () => props.size ?? 240;
const showGrid = () => props.showGrid ?? true;
const [internalPos, setInternalPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
const [dragging, setDragging] = createSignal(false);
let containerEl: HTMLDivElement | undefined;
const pos = () => props.position?.() ?? internalPos();
const updateFromEvent = (e: PointerEvent) => {
if (!containerEl) return;
const rect = containerEl.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / rect.height));
if (props.position === undefined) setInternalPos([x, y]);
props.onMove(x, y);
};
const onPointerDown = (e: PointerEvent) => {
if (props.disabled) return;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
setDragging(true);
props.onGrab?.();
updateFromEvent(e);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging()) return;
updateFromEvent(e);
};
const onPointerUp = (e: PointerEvent) => {
if (!dragging()) return;
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
setDragging(false);
props.onRelease?.();
};
const onKey = (e: KeyboardEvent) => {
if (props.disabled) return;
const step = e.shiftKey ? 0.05 : 0.01;
let [x, y] = pos();
let handled = true;
switch (e.key) {
case 'ArrowLeft': x -= step; break;
case 'ArrowRight': x += step; break;
case 'ArrowUp': y += step; break;
case 'ArrowDown': y -= step; break;
case 'Home': x = 0.5; y = 0.5; break;
default: handled = false;
}
if (handled) {
e.preventDefault();
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
if (props.position === undefined) setInternalPos([x, y]);
props.onMove(x, y);
}
};
const dotStyle = createMemo(() => {
const [x, y] = pos();
const px = x * size();
const py = (1 - y) * size();
return `transform: translate(${px}px, ${py}px) translate(-50%, -50%);`;
});
return (
<div
ref={containerEl}
class={styles.pad}
classList={{ [styles.disabled]: !!props.disabled, [styles.dragging]: dragging() }}
style={{ width: `${size()}px`, height: `${size()}px` }}
role="application"
tabIndex={props.disabled ? -1 : 0}
aria-label={props.ariaLabel ?? 'XY pad'}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKey}
>
{showGrid() && (
<div class={styles.grid} aria-hidden="true">
<div class={styles.gridH} />
<div class={styles.gridV} />
</div>
)}
<div class={styles.dot} style={dotStyle()} aria-hidden="true" />
</div>
);
};

View file

@ -0,0 +1,23 @@
/**
* Primitive library entry-point.
*
* Re-exports every UI primitive together with its props type. Modes and the
* dev showcase import everything from here.
*/
export { Slider, type SliderProps } from './Slider';
export { SliderBank, type SliderBankProps, type SliderConfig } from './SliderBank';
export { VirtualJoystick, type VirtualJoystickProps } from './VirtualJoystick';
export { XYPad, type XYPadProps } from './XYPad';
export { Heatmap, type HeatmapProps, type HeatmapColorMode } from './Heatmap';
export { OutputDisplay, type OutputDisplayProps } from './OutputDisplay';
export { TrainingControls, type TrainingControlsProps } from './TrainingControls';
export { Drawer, type DrawerProps } from './Drawer';
export { ControlAxis, type ControlAxisProps } from './ControlAxis';
export { ProgressRing, type ProgressRingProps } from './ProgressRing';
export { PillToggle, type PillToggleProps, type PillToggleOption } from './PillToggle';
export { ParamEditor, type ParamEditorProps, type ParamDef, type ParamOverride } from './ParamEditor';
export { JoyMap, type JoyMapProps, type TrailPoint, type RegionPin } from './JoyMap';
export { WeightHealth, type WeightHealthProps, type WeightStatus } from './WeightHealth';
export { GradientFlow, type GradientFlowProps, type GradientStatus } from './GradientFlow';
export { LossPlot, type LossPlotProps } from './LossPlot';