feat(playground): scaffold vite + solid + ts

Fresh playground/ now sits on:
- Vite 5 + vite-plugin-solid + Solid.js 1.9
- TypeScript strict (jsx preserve, target ES2022)
- index.html → main.tsx → App.tsx with a tiny pushState/popstate router
- Design tokens (dark immersive palette, JetBrains Mono) at src/styles/tokens.css
- COOP/COEP headers in vite.config.ts (server + preview) so SharedArrayBuffer
  is available for the C15 + AudioWorklet wiring stream 7 will land.
- Debug-probe stub at src/debug/probe.ts: window.__nisps with placeholder
  methods + __ready=false marker. Stream 10 fills it in. Keeping the install
  point stable from day one means Playwright tests can rely on the global
  existing.

Stores, pipelines, and primitives ride in the next commits.

Stream 8 of the rewrite (meml-911).
This commit is contained in:
w1n5t0n 2026-04-29 15:37:23 +03:00
parent d41e4c3e9f
commit a160b72295
11 changed files with 519 additions and 0 deletions

5
playground/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
.vite
*.log
.DS_Store

13
playground/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0d0d0d" />
<title>MEMLNaut Playground</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
playground/package.json Normal file
View file

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

View file

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

71
playground/src/App.tsx Normal file
View file

@ -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<Route>(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 (
<div class={styles.app}>
<header class={styles.header}>
<strong>MEMLNaut</strong>
<span class={styles.dim}>playground</span>
<nav class={styles.nav}>
<button
type="button"
class={route() === 'home' ? styles.active : ''}
onClick={() => navigate('/')}
>
home
</button>
</nav>
</header>
<main class={styles.main}>
<Show when={route() === 'home'}>
<Home />
</Show>
<Show when={route() === 'unknown'}>
<div class={styles.notFound}>
<h1>404</h1>
<p>No route at <code>{window.location.pathname}</code>.</p>
<p>
<a href="/" onClick={(e) => { e.preventDefault(); navigate('/'); }}>back to home</a>
</p>
</div>
</Show>
</main>
</div>
);
};
const Home: Component = () => {
return (
<div class={styles.home}>
<h1 class={styles.title}>MEMLNaut Playground</h1>
<p class={styles.tagline}>
Interactive ML control of audio. SolidJS scaffold modes coming online in stream 9.
</p>
<p class={styles.note}>
This is a fresh scaffold. Primitives, stores, ML, WASM, and audio engines are added in subsequent commits.
</p>
</div>
);
};
export default App;

View file

@ -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<loss>. */
trainAsync(): Promise<number>;
/** 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<readonly [number, number]>): 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;
}

17
playground/src/main.tsx Normal file
View file

@ -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(() => <App />, root);

View file

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

8
playground/src/types.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}
declare module '*.css';

32
playground/tsconfig.json Normal file
View file

@ -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"]
}

25
playground/vite.config.ts Normal file
View file

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