diff --git a/playground/.gitignore b/playground/.gitignore new file mode 100644 index 0000000..dfa9518 --- /dev/null +++ b/playground/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.vite +*.log +.DS_Store diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000..60b50c7 --- /dev/null +++ b/playground/index.html @@ -0,0 +1,13 @@ + + + + + + + MEMLNaut Playground + + +
+ + + diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 0000000..df92492 --- /dev/null +++ b/playground/package.json @@ -0,0 +1,23 @@ +{ + "name": "memlnaut-playground", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --port 4173", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "solid-js": "^1.8.22" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.7.0", + "typescript": "^5.5.4", + "vite": "^5.4.10", + "vite-plugin-solid": "^2.10.2" + } +} diff --git a/playground/src/App.module.css b/playground/src/App.module.css new file mode 100644 index 0000000..7659dd0 --- /dev/null +++ b/playground/src/App.module.css @@ -0,0 +1,84 @@ +.app { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.header { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--line); + background: var(--bg-1); +} + +.dim { + color: var(--fg-mute); + font-size: var(--fs-sm); +} + +.nav { + margin-left: auto; + display: flex; + gap: var(--sp-2); +} + +.nav button { + padding: var(--sp-1) var(--sp-3); + font-size: var(--fs-sm); +} + +.active { + border-color: var(--accent) !important; + color: var(--accent); +} + +.main { + flex: 1; + padding: var(--sp-5); + overflow: auto; +} + +.home { + max-width: 720px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.title { + font-size: var(--fs-xl); + margin: 0; + color: var(--accent); +} + +.tagline { + color: var(--fg-mute); + margin: 0; +} + +.linkList { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.note { + color: var(--fg-dim); + font-size: var(--fs-sm); + margin-top: var(--sp-4); +} + +.notFound { + max-width: 540px; + margin: var(--sp-6) auto; +} + +.notFound h1 { + color: var(--accent); +} diff --git a/playground/src/App.tsx b/playground/src/App.tsx new file mode 100644 index 0000000..cb35056 --- /dev/null +++ b/playground/src/App.tsx @@ -0,0 +1,71 @@ +import { Component, createSignal, onCleanup, Show } from 'solid-js'; +import styles from './App.module.css'; + +type Route = 'home' | 'unknown'; + +function parseRoute(path: string): Route { + if (path === '' || path === '/' || path === '/index.html') return 'home'; + return 'unknown'; +} + +const App: Component = () => { + const [route, setRoute] = createSignal(parseRoute(window.location.pathname)); + + const onPop = () => setRoute(parseRoute(window.location.pathname)); + window.addEventListener('popstate', onPop); + onCleanup(() => window.removeEventListener('popstate', onPop)); + + const navigate = (path: string) => { + if (window.location.pathname === path) return; + window.history.pushState({}, '', path); + setRoute(parseRoute(path)); + }; + + return ( +
+
+ MEMLNaut + playground + +
+
+ + + + +
+

404

+

No route at {window.location.pathname}.

+

+ { e.preventDefault(); navigate('/'); }}>back to home +

+
+
+
+
+ ); +}; + +const Home: Component = () => { + return ( +
+

MEMLNaut Playground

+

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

+
+ ); +}; + +export default App; diff --git a/playground/src/debug/probe.ts b/playground/src/debug/probe.ts new file mode 100644 index 0000000..f241afc --- /dev/null +++ b/playground/src/debug/probe.ts @@ -0,0 +1,118 @@ +/** + * Debug probe: window.__nisps + * + * Stream 8 (this stream) installs a stub that returns placeholder values. + * Stream 10 wires real ML calls. Keeping the install path stable here means + * Playwright tests can rely on `window.__nisps` existing from page load even + * before the ML engine boots. + * + * All methods MUST be synchronous (or return immediately-resolved promises). + * The probe deliberately bypasses Solid reactivity so tests get deterministic, + * imperative semantics. + */ + +export interface DebugProbe { + /** Current 126-element output vector (Float32Array). */ + getOutputs(): Float32Array; + /** Last training loss, or null if no training has occurred. */ + getLoss(): number | null; + /** Flat weight array (~13K floats once wired). */ + getWeights(): Float32Array; + /** Number of training examples currently in the dataset. */ + getExampleCount(): number; + /** Set joystick X/Y in [0,1] and run inference. */ + setInputs(x: number, y: number): void; + /** Trigger thumbs-up RL feedback (train + decay noise). */ + thumbsUp(): void; + /** Trigger thumbs-down RL feedback (move weights + grow noise). */ + thumbsDown(): void; + /** Synchronous training; returns final loss. */ + train(): number; + /** Async training; returns Promise. */ + trainAsync(): Promise; + /** Randomize weights with current spread. */ + randomise(): void; + /** Clear all training examples. */ + clearExamples(): void; + /** Force a save to localStorage now (no debounce). */ + saveState(): void; + /** Non-destructive loss query against current dataset. */ + evalLoss(): number | null; + /** Batch inference: input is Nx2 array of [x,y] pairs. Output: Float32Array of N*outputSize. */ + inferBatch(points: ReadonlyArray): Float32Array; + /** Per-layer weight statistics: Float32Array of layerCount * 4 (mean|w|, max|w|, dead%, sat%). */ + getLayerStats(): Float32Array; + /** Marker showing this is a stream-8 stub. Tests can read this to skip when not ready. */ + readonly __ready: boolean; +} + +declare global { + interface Window { + __nisps?: DebugProbe; + } +} + +const EMPTY_F32 = new Float32Array(0); + +const stubProbe: DebugProbe = { + getOutputs() { + return EMPTY_F32; + }, + getLoss() { + return null; + }, + getWeights() { + return EMPTY_F32; + }, + getExampleCount() { + return 0; + }, + setInputs(_x: number, _y: number) { + /* no-op until ML wired */ + }, + thumbsUp() { + /* no-op */ + }, + thumbsDown() { + /* no-op */ + }, + train() { + return 0; + }, + trainAsync() { + return Promise.resolve(0); + }, + randomise() { + /* no-op */ + }, + clearExamples() { + /* no-op */ + }, + saveState() { + /* no-op */ + }, + evalLoss() { + return null; + }, + inferBatch(points) { + // Return a zero array of the right size for at least the inputs. + return new Float32Array(points.length); + }, + getLayerStats() { + return EMPTY_F32; + }, + __ready: false, +}; + +/** + * Install the probe on window. Idempotent. + * + * Stream 10 will replace this with a fully-wired version. Until then the stub + * advertises `__ready === false`, letting tests skip ML-dependent assertions. + */ +export function installDebugProbe(): void { + if (typeof window === 'undefined') return; + // Always overwrite — later streams may replace it; the marker prevents stale + // probes from passing tests. + window.__nisps = stubProbe; +} diff --git a/playground/src/main.tsx b/playground/src/main.tsx new file mode 100644 index 0000000..3fdf670 --- /dev/null +++ b/playground/src/main.tsx @@ -0,0 +1,17 @@ +/* @refresh reload */ +import { render } from 'solid-js/web'; +import App from './App'; +import './styles/tokens.css'; +import { installDebugProbe } from './debug/probe'; + +const root = document.getElementById('root'); +if (!root) { + throw new Error('Root element #root not found'); +} + +// Install debug probe early. It is a stub for now; stream 10 fills it in +// once WASM ML is wired up. Keeping the install path stable from day one +// makes Playwright tests insensitive to ordering. +installDebugProbe(); + +render(() => , root); diff --git a/playground/src/styles/tokens.css b/playground/src/styles/tokens.css new file mode 100644 index 0000000..ccab3e0 --- /dev/null +++ b/playground/src/styles/tokens.css @@ -0,0 +1,123 @@ +/** + * Global design tokens for the MEMLNaut playground. + * Carries forward the immersive identity: dark canvas, monospace, warm + cool accents. + */ + +:root { + /* Colors */ + --bg: #0d0d0d; + --bg-1: #141414; + --bg-2: #1c1c1c; + --bg-3: #242424; + --fg: #e8e8e8; + --fg-mute: #9a9a9a; + --fg-dim: #5a5a5a; + --line: #2a2a2a; + --line-strong: #3a3a3a; + + --accent: #ff6a00; /* warm primary */ + --accent-2: #00ccff; /* cool secondary */ + --accent-3: #ffa860; /* hover warm */ + --good: #6bc26b; + --warn: #f5c45e; + --bad: #ef5b5b; + --info: #5b9eef; + + --pin-1: rgba(255, 106, 0, 0.25); + --pin-2: rgba(0, 204, 255, 0.25); + --pin-3: rgba(180, 100, 255, 0.25); + --pin-4: rgba(80, 200, 120, 0.25); + --pin-5: rgba(255, 200, 80, 0.25); + + /* Typography */ + --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, Consolas, monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + + --fs-xs: 11px; + --fs-sm: 13px; + --fs-md: 15px; + --fs-lg: 18px; + --fs-xl: 24px; + + /* Spacing */ + --sp-0: 2px; + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + + /* Radius */ + --r-1: 4px; + --r-2: 8px; + --r-3: 14px; + --r-pill: 999px; + + /* Motion */ + --ease: cubic-bezier(.25, .8, .35, 1); + --dur-fast: 120ms; + --dur-med: 220ms; + --dur-slow: 360ms; + + /* Layers */ + --z-bg: 0; + --z-content: 10; + --z-overlay: 100; + --z-drawer: 200; + --z-modal: 1000; +} + +* { + box-sizing: border-box; +} + +html, body, #root { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: var(--font-mono); + font-size: var(--fs-md); + -webkit-tap-highlight-color: transparent; +} + +button { + font-family: inherit; + font-size: inherit; + background: var(--bg-2); + color: var(--fg); + border: 1px solid var(--line); + border-radius: var(--r-1); + padding: var(--sp-2) var(--sp-3); + cursor: pointer; + transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease); +} +button:hover:not(:disabled) { + background: var(--bg-3); + border-color: var(--line-strong); +} +button:disabled { + color: var(--fg-dim); + cursor: not-allowed; +} + +input, select { + font-family: inherit; + font-size: inherit; + color: var(--fg); +} + +a { + color: var(--accent-2); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +::selection { + background: var(--accent); + color: var(--bg); +} diff --git a/playground/src/types.d.ts b/playground/src/types.d.ts new file mode 100644 index 0000000..cabcafe --- /dev/null +++ b/playground/src/types.d.ts @@ -0,0 +1,8 @@ +/// + +declare module '*.module.css' { + const classes: Record; + export default classes; +} + +declare module '*.css'; diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 0000000..55233af --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "verbatimModuleSyntax": false, + "forceConsistentCasingInFileNames": true, + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "noEmit": true + }, + "include": ["src/**/*", "vite.config.ts"], + "exclude": ["node_modules", "dist", "tests/e2e"] +} diff --git a/playground/vite.config.ts b/playground/vite.config.ts new file mode 100644 index 0000000..1154fc9 --- /dev/null +++ b/playground/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import solid from 'vite-plugin-solid'; + +// COOP/COEP headers are required for SharedArrayBuffer (used by C15 + AudioWorklet). +// Set on both dev server and the production preview. +const crossOriginIsolationHeaders = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', +}; + +export default defineConfig({ + plugins: [solid()], + server: { + port: 5173, + headers: crossOriginIsolationHeaders, + }, + preview: { + port: 4173, + headers: crossOriginIsolationHeaders, + }, + build: { + target: 'es2022', + sourcemap: true, + }, +});