Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/thatdeveloperguy-website / docs/superpowers/plans/2026-09-15-lobby-hero.md |
| Date | Not specified in source |
| Repository | Janady13/thatdeveloperguy-website |
| Revision | 2bea2ebdff0e34494bff597d4fa9c8422a0c3a94 |
| Branch | main |
| Path or archive member | docs/superpowers/plans/2026-09-15-lobby-hero.md |
| Original SHA-256 | f8ab115692edd570d39a9859f2b4be73e46a9d633a853edbf824538d5120bf8f |
| Git blob SHA | e67cece85b1bc2ac2ea9fbc2d43148b4b2468870 |
| Reading-copy SHA-256 | f8ab115692edd570d39a9859f2b4be73e46a9d633a853edbf824538d5120bf8f |
Recovered source transcript
Download complete source text ↓2,200 lines · 121.9 KB
# Lobby Hero Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A working `npm run dev` lobby for thatdeveloperguy.com: the kit's traced lobby refined to clean vector, authored as a Rive artboard through the editor MCP, exported to `.riv`, verified headlessly, and played in a React stage where the three doors are real links that open in Rive and navigate.
**Architecture:** Three layers that only meet through files: `tools/` turns the Desktop kit into `scenes/refined/lobby/` (SVG + manifest) and drives the Rive editor over HTTP to produce `scenes/rive/lobby/lobby.riv` + `rive-manifest.json`; `apps/web/` is a Vite/React 19 site whose `SceneStage` plays the `.riv`, overlays real `<a>` hit shapes from the kit's hotspots, and writes view-model properties (`focus`, door triggers). Nothing under `scenes/` is hand-edited.
**Tech Stack:** Node 24.21 (`~/.local/node/node-v24.21.0-darwin-arm64/bin`), TypeScript 7.0.2 via `tsx`, `node --test`, Vite 8.3.0 + React 19.3.0 + react-router 8.3.1, `@rive-app/react-webgl2` 4.34.2, `@rive-app/canvas-advanced` 2.42.1, `@xmldom/xmldom` 0.9.12, `fit-curve` 0.2.0, `polygon-clipping` 0.15.7, `@resvg/resvg-js` 2.6.2, `pixelmatch` 7.2.0, `pngjs` 7.0.0, Playwright 1.63.0, `@axe-core/playwright` 4.13.0.
**Spec:** `docs/superpowers/specs/2026-09-15-thatdeveloperguy-rive-site-design.md`
## Global Constraints
- Node `>=24.19.0 <25`; `.npmrc` has `engine-strict=true`; every command below runs with `export PATH=~/.local/node/node-v24.21.0-darwin-arm64/bin:$PATH`.
- The kits under `~/Desktop/ThatDeveloperGuy/` are **read-only inputs**; never write into them.
- `scenes/refined/**` and `scenes/rive/**` are generated only by `tools/`; commit the generated files.
- Artboard is `1648 × 928`; room coordinates are kit coordinates; artboard name `Lobby`.
- Rive editor MCP: `http://127.0.0.1:9791/mcp`, Rive Early Access must be running with the target file active. `upload_asset` must use a `data:` URI; `export_file` must use `inline_base64: true` (editor is sandboxed).
- Content wording comes from `~/Code/thatdeveloperguy-truth/organization.json` unchanged; government-contracting facts the node lacks are rendered as `owner-to-supply` placeholders, never invented.
- Navigation and copy are HTML; the `.riv` is motion only. Every hotspot is a real `<a>` with `aria-label`, tabbable.
- No new `.md` reports; results are printed to the terminal and written as JSON manifests.
- Commit after every task with the trailer:
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01Te9aj6wYECisNq9sKX1qfR`.
---
## File structure (this plan)
| Path | Responsibility |
| --- | --- |
| `package.json`, `.npmrc`, `tsconfig.json`, `.gitignore` | toolchain pins, test runner |
| `scenes/kits.config.json` | absolute kit paths, per-room file names, hotspot/manifest locations |
| `tools/kits.ts` | load `kits.config.json`, resolve a room's kit files, checksum them |
| `tools/svg/model.ts` | `SceneDoc`, `GroupNode`, `PathNode`, `Ring` types |
| `tools/svg/parse.ts` | kit SVG → `SceneDoc` (groups with ids, paths with fill + polygon rings) |
| `tools/svg/geometry.ts` | ring area, corner detection, cubic fitting (`fit-curve`), union (`polygon-clipping`) |
| `tools/svg/refine.ts` | speckle drop → union → curve fit → `RefinedDoc` + stats |
| `tools/svg/serialize.ts` | `RefinedDoc` → compact SVG text; part extraction |
| `tools/svg/diff.ts` | render SVG with resvg, compare to the reference PNG with pixelmatch |
| `tools/refine-vectors.ts` | CLI: `--room lobby` → `scenes/refined/lobby/{scene.svg,parts/*.svg,manifest.json}` |
| `tools/rive-mcp.ts` | `RiveEditor` JSON-RPC client: call, uploadSvg, capture, exportRiv, log |
| `tools/rive-plan.ts` | pure: refined manifest + hotspots → ordered authoring steps |
| `tools/rive-author.ts` | CLI: executes the plan against the editor, writes `scenes/rive/lobby/` |
| `tools/verify-riv.ts` | CLI: loads `.riv` headlessly, asserts artboard/animations/state machine/view model |
| `tools/import-truth.ts` | node `organization.json` → `apps/web/src/truth/projection.json` |
| `apps/web/index.html`, `vite.config.ts`, `src/main.tsx`, `src/App.tsx` | app shell + routes |
| `apps/web/src/styles/{tokens,base,layout,stage}.css` | palette, type, stage geometry |
| `apps/web/src/animation/rive/RiveScene.tsx` | plays a room `.riv`, exposes `setFocus`/`fire` |
| `apps/web/src/animation/stage/SceneStage.tsx` | poster + Rive + hit-shape overlay + caption |
| `apps/web/src/rooms/lobby/{hotspots.ts,content.ts,Lobby.tsx}` | the lobby page |
| `apps/web/src/rooms/{it-services,government-solutions,cybersecurity}/Room.tsx` | placeholder rooms (Plan 2 fills them) |
| `apps/web/src/components/{Header,Footer}.tsx` | chrome |
| `scripts/sync-rive-wasm.mjs`, `scripts/sync-scenes.mjs` | copy runtime WASM and scene files into `apps/web/public/` |
| `tools/shots.ts` | headless Chrome fps + screenshots of `/` |
| `tests/unit/*.test.ts`, `tests/browser/lobby.spec.ts`, `playwright.config.ts` | tests |
---
### Task 1: Repository toolchain
**Files:**
- Create: `package.json`, `.npmrc`, `tsconfig.json`, `.gitignore`, `scenes/kits.config.json`, `tools/kits.ts`
- Test: `tests/unit/kits.test.ts`
**Interfaces:**
- Produces: `loadKits(): KitsConfig`, `resolveRoom(room: RoomId): RoomKit` where
`type RoomId = 'lobby' | 'it-services' | 'government-solutions' | 'cybersecurity'` and
`interface RoomKit { room: RoomId; dir: string; svg: string; manifest: string; hotspots: string; reference: string }` (absolute paths).
- [ ] **Step 1: Write the toolchain files**
`package.json`:
```json
{
"name": "thatdeveloperguy-website",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "thatdeveloperguy.com — illustrated Rive site",
"engines": { "node": ">=24.19.0 <25" },
"scripts": {
"dev": "vite --config apps/web/vite.config.ts --host 127.0.0.1 --port 4410",
"build": "vite build --config apps/web/vite.config.ts",
"typecheck": "tsc --noEmit",
"test": "node --test --import tsx tests/unit/*.test.ts",
"test:browser": "playwright test",
"refine": "node --import tsx tools/refine-vectors.ts",
"rive:author": "node --import tsx tools/rive-author.ts",
"rive:verify": "node --import tsx tools/verify-riv.ts",
"truth": "node --import tsx tools/import-truth.ts",
"sync": "node scripts/sync-rive-wasm.mjs && node scripts/sync-scenes.mjs",
"shots": "node --import tsx tools/shots.ts"
},
"dependencies": {
"@rive-app/react-webgl2": "4.34.2",
"gsap": "3.15.0",
"lenis": "1.3.26",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.3.1"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@playwright/test": "1.63.0",
"@resvg/resvg-js": "2.6.2",
"@rive-app/canvas-advanced": "2.42.1",
"@types/node": "24.13.4",
"@types/pixelmatch": "5.2.6",
"@types/pngjs": "6.0.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"@xmldom/xmldom": "0.9.12",
"fit-curve": "0.2.0",
"pixelmatch": "7.2.0",
"pngjs": "7.0.0",
"polygon-clipping": "0.15.7",
"tsx": "4.23.13",
"typescript": "7.0.2",
"vite": "8.3.0"
}
}
```
`.npmrc`:
```
engine-strict=true
```
`tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"],
"lib": ["ES2023", "DOM", "DOM.Iterable"]
},
"include": ["apps/web/src", "tools", "tests", "scripts"]
}
```
`.gitignore`:
```
node_modules/
apps/web/dist/
apps/web/public/scenes/
apps/web/public/animation/
.tools/
test-results/
shots/
*.log
.DS_Store
```
`scenes/kits.config.json`:
```json
{
"root": "/Users/josephanady/Desktop/ThatDeveloperGuy",
"canvas": { "width": 1648, "height": 928 },
"rooms": {
"lobby": {
"dir": "TDG_LOBBY_ANIMATION_KIT",
"svg": "svg/tdg-lobby.rive-import.svg",
"manifest": "docs/scene-manifest.json",
"hotspots": "web/hotspots.json",
"reference": "source/tdg-lobby.reference.png"
},
"it-services": {
"dir": "TDG_IT_SERVICES_ANIMATION_KIT",
"svg": "svg/tdg-it-services.rive-import.svg",
"manifest": "docs/scene-manifest.json",
"hotspots": "web/hotspots.json",
"reference": "source/tdg-it-services.reference.png"
},
"government-solutions": {
"dir": "TDG_GOVERNMENT_SOLUTIONS_ANIMATION_KIT",
"svg": "svg/tdg-government-solutions.rive-import.svg",
"manifest": "docs/scene-manifest.json",
"hotspots": "web/hotspots.json",
"reference": "source/tdg-government-solutions.reference.png"
},
"cybersecurity": {
"dir": "TDG_CYBERSECURITY_ANIMATION_KIT",
"svg": "svg/tdg-cybersecurity.rive-import.svg",
"manifest": "docs/scene-manifest.json",
"hotspots": "web/hotspots.json",
"reference": "source/tdg-cybersecurity.reference.png"
}
}
}
```
- [ ] **Step 2: Write the failing test**
`tests/unit/kits.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { loadKits, resolveRoom } from '../../tools/kits.ts';
test('kits config names four rooms on a 1648×928 canvas', () => {
const kits = loadKits();
assert.deepEqual(Object.keys(kits.rooms).sort(), ['cybersecurity', 'government-solutions', 'it-services', 'lobby']);
assert.deepEqual(kits.canvas, { width: 1648, height: 928 });
});
test('resolveRoom returns absolute paths that exist for the lobby', () => {
const kit = resolveRoom('lobby');
for (const key of ['svg', 'manifest', 'hotspots', 'reference'] as const) {
assert.ok(kit[key].startsWith('/'), `${key} must be absolute`);
assert.ok(existsSync(kit[key]), `${key} missing: ${kit[key]}`);
}
});
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/kits.ts'`
- [ ] **Step 4: Write `tools/kits.ts`**
```ts
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export type RoomId = 'lobby' | 'it-services' | 'government-solutions' | 'cybersecurity';
export interface KitEntry { dir: string; svg: string; manifest: string; hotspots: string; reference: string }
export interface KitsConfig { root: string; canvas: { width: number; height: number }; rooms: Record<RoomId, KitEntry> }
export interface RoomKit { room: RoomId; dir: string; svg: string; manifest: string; hotspots: string; reference: string }
export const repoRoot = fileURLToPath(new URL('..', import.meta.url));
export function loadKits(): KitsConfig {
return JSON.parse(readFileSync(resolve(repoRoot, 'scenes/kits.config.json'), 'utf8')) as KitsConfig;
}
/** Absolute paths for one room's kit files. The kit directory is read-only input. */
export function resolveRoom(room: RoomId, kits: KitsConfig = loadKits()): RoomKit {
const entry = kits.rooms[room];
if (!entry) throw new Error(`Unknown room ${room}`);
const dir = resolve(kits.root, entry.dir);
return { room, dir, svg: resolve(dir, entry.svg), manifest: resolve(dir, entry.manifest), hotspots: resolve(dir, entry.hotspots), reference: resolve(dir, entry.reference) };
}
```
- [ ] **Step 5: Install and run the test**
Run: `npm install && npm test`
Expected: PASS (2 tests)
- [ ] **Step 6: Commit**
```bash
git add package.json package-lock.json .npmrc tsconfig.json .gitignore scenes/kits.config.json tools/kits.ts tests/unit/kits.test.ts
git commit -m "Toolchain, kit config and kit resolver"
```
---
### Task 2: Parse a kit SVG into a scene model
**Files:**
- Create: `tools/svg/model.ts`, `tools/svg/parse.ts`
- Test: `tests/unit/svg-parse.test.ts`, `tests/fixtures/mini-scene.svg`
**Interfaces:**
- Produces:
```ts
export type Point = [number, number];
export type Ring = Point[]; // closed polygon; last point ≠ first
export interface PathNode { kind: 'path'; id: string; fill: string; rings: Ring[] }
export interface GroupNode { kind: 'group'; id: string; label?: string; children: SvgNode[] }
export type SvgNode = PathNode | GroupNode;
export interface SceneDoc { width: number; height: number; title: string; children: SvgNode[] }
export function parseScene(svgText: string): SceneDoc;
export function parsePathData(d: string): Ring[]; // M/m L/l H/h V/v Z/z only; throws on curves/arcs
export function countRings(doc: SceneDoc): { paths: number; rings: number; vertices: number };
```
- [ ] **Step 1: Write the fixture and the failing test**
`tests/fixtures/mini-scene.svg`:
```xml
<?xml version='1.0' encoding='UTF-8'?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="100" height="50" viewBox="0 0 100 50" version="1.1"><title>mini</title><g id="cleanplates" inkscape:groupmode="layer" inkscape:label="00 — underlays"><path d="M0 0H100V50H0Z" fill="#f2eef8" id="cleanplate_paper"/></g><g id="door_it" inkscape:groupmode="layer" inkscape:label="10 — door"><g id="door_it_leaf"><path d="M10 10L30 10L30 40L10 40ZM12.7 12.7L12.7 13.3L13.3 13.3L13.3 12.7Z" fill="#3b2a6e" id="door_it_leaf__p00001"/><path d="M15 15h10v10h-10z" fill="#ffffff" id="door_it_leaf__p00002"/></g></g></svg>
```
`tests/unit/svg-parse.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { parseScene, parsePathData, countRings } from '../../tools/svg/parse.ts';
const fixture = readFileSync(new URL('../fixtures/mini-scene.svg', import.meta.url), 'utf8');
test('parsePathData handles M/L/H/V/Z absolute and relative, multiple subpaths', () => {
assert.deepEqual(parsePathData('M0 0H100V50H0Z'), [[[0, 0], [100, 0], [100, 50], [0, 50]]]);
assert.deepEqual(parsePathData('M15 15h10v10h-10z'), [[[15, 15], [25, 15], [25, 25], [15, 25]]]);
assert.equal(parsePathData('M10 10L30 10L30 40L10 40ZM12.7 12.7L12.7 13.3L13.3 13.3L13.3 12.7Z').length, 2);
assert.throws(() => parsePathData('M0 0C1 1 2 2 3 3Z'), /unsupported/);
});
test('parseScene keeps the group tree, ids, labels and fills', () => {
const doc = parseScene(fixture);
assert.equal(doc.width, 100); assert.equal(doc.height, 50); assert.equal(doc.title, 'mini');
assert.equal(doc.children.length, 2);
const door = doc.children[1]!;
assert.equal(door.kind, 'group'); assert.equal(door.id, 'door_it'); assert.equal((door as any).label, '10 — door');
const leaf = (door as any).children[0];
assert.equal(leaf.id, 'door_it_leaf');
assert.equal(leaf.children[0].fill, '#3b2a6e');
assert.equal(leaf.children[0].rings.length, 2);
assert.deepEqual(countRings(doc), { paths: 3, rings: 4, vertices: 16 });
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/svg/parse.ts'`
- [ ] **Step 3: Write `tools/svg/model.ts` and `tools/svg/parse.ts`**
`tools/svg/model.ts`:
```ts
export type Point = [number, number];
export type Ring = Point[];
export interface PathNode { kind: 'path'; id: string; fill: string; rings: Ring[] }
export interface GroupNode { kind: 'group'; id: string; label?: string; children: SvgNode[] }
export type SvgNode = PathNode | GroupNode;
export interface SceneDoc { width: number; height: number; title: string; children: SvgNode[] }
export function* walkPaths(nodes: SvgNode[]): Generator<PathNode> {
for (const node of nodes) {
if (node.kind === 'path') yield node; else yield* walkPaths(node.children);
}
}
export function findGroup(nodes: SvgNode[], id: string): GroupNode | null {
for (const node of nodes) {
if (node.kind !== 'group') continue;
if (node.id === id) return node;
const inner = findGroup(node.children, id);
if (inner) return inner;
}
return null;
}
```
`tools/svg/parse.ts`:
```ts
import { DOMParser } from '@xmldom/xmldom';
import type { GroupNode, PathNode, Ring, SceneDoc, SvgNode } from './model.ts';
import { walkPaths } from './model.ts';
const INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape';
const TOKEN = /([MmLlHhVvZzCcSsQqTtAa])|(-?\d*\.?\d+(?:e-?\d+)?)/g;
/** Kit path grammar is polygons only: M/L/H/V/Z. Anything else is a bug upstream, not something to guess at. */
export function parsePathData(d: string): Ring[] {
const rings: Ring[] = [];
let ring: Ring = [];
let cmd = ''; let x = 0; let y = 0; let startX = 0; let startY = 0;
const numbers: number[] = [];
const flush = () => { if (ring.length >= 3) rings.push(ring); ring = []; };
const tokens = [...d.matchAll(TOKEN)];
let i = 0;
const num = () => { const t = tokens[i++]; if (!t || t[2] === undefined) throw new Error(`path data: expected number in "${d.slice(0, 40)}"`); return Number(t[2]); };
while (i < tokens.length) {
const t = tokens[i]!;
if (t[1]) { cmd = t[1]; i++; if (cmd === 'Z' || cmd === 'z') { flush(); x = startX; y = startY; continue; } if ('CcSsQqTtAa'.includes(cmd)) throw new Error(`path data: unsupported command ${cmd}`); }
switch (cmd) {
case 'M': flush(); x = num(); y = num(); startX = x; startY = y; ring.push([x, y]); cmd = 'L'; break;
case 'm': flush(); x += num(); y += num(); startX = x; startY = y; ring.push([x, y]); cmd = 'l'; break;
case 'L': x = num(); y = num(); ring.push([x, y]); break;
case 'l': x += num(); y += num(); ring.push([x, y]); break;
case 'H': x = num(); ring.push([x, y]); break;
case 'h': x += num(); ring.push([x, y]); break;
case 'V': y = num(); ring.push([x, y]); break;
case 'v': y += num(); ring.push([x, y]); break;
default: throw new Error(`path data: unexpected token near "${d.slice(0, 40)}"`);
}
numbers.length = 0;
}
flush();
return rings;
}
function element(node: Element): SvgNode | null {
if (node.localName === 'g') {
const children: SvgNode[] = [];
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
if (child && child.nodeType === 1) { const parsed = element(child as Element); if (parsed) children.push(parsed); }
}
const group: GroupNode = { kind: 'group', id: node.getAttribute('id') ?? '', children };
const label = node.getAttributeNS(INKSCAPE, 'label');
if (label) group.label = label;
return group;
}
if (node.localName === 'path') {
const path: PathNode = { kind: 'path', id: node.getAttribute('id') ?? '', fill: (node.getAttribute('fill') ?? '#000000').toLowerCase(), rings: parsePathData(node.getAttribute('d') ?? '') };
return path;
}
if (['image', 'text', 'filter', 'mask', 'use'].includes(node.localName ?? '')) throw new Error(`kit SVG contains <${node.localName}>; the pipeline only accepts groups and polygon paths`);
return null;
}
export function parseScene(svgText: string): SceneDoc {
const dom = new DOMParser({ onError: (level, message) => { if (level === 'fatalError') throw new Error(message); } }).parseFromString(svgText, 'image/svg+xml');
const svg = dom.documentElement!;
const children: SvgNode[] = [];
let title = '';
for (let i = 0; i < svg.childNodes.length; i++) {
const child = svg.childNodes[i];
if (!child || child.nodeType !== 1) continue;
const el = child as Element;
if (el.localName === 'title') { title = el.textContent ?? ''; continue; }
if (el.localName === 'desc') continue;
const parsed = element(el);
if (parsed) children.push(parsed);
}
return { width: Number(svg.getAttribute('width')), height: Number(svg.getAttribute('height')), title, children };
}
export function countRings(doc: SceneDoc): { paths: number; rings: number; vertices: number } {
let paths = 0, rings = 0, vertices = 0;
for (const path of walkPaths(doc.children)) { paths++; for (const ring of path.rings) { rings++; vertices += ring.length; } }
return { paths, rings, vertices };
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test`
Expected: PASS (4 tests)
- [ ] **Step 5: Prove it on the real lobby file**
Run: `node --import tsx -e "import {readFileSync} from 'node:fs'; import {parseScene,countRings} from './tools/svg/parse.ts'; import {resolveRoom} from './tools/kits.ts'; const d=parseScene(readFileSync(resolveRoom('lobby').svg,'utf8')); console.log(countRings(d), d.children.map(c=>c.id));"`
Expected: `{ paths: 2789, rings: 60007, vertices: 390849 }` and the top-level layer ids (starting with `cleanplates`).
- [ ] **Step 6: Commit**
```bash
git add tools/svg/model.ts tools/svg/parse.ts tests/unit/svg-parse.test.ts tests/fixtures/mini-scene.svg
git commit -m "Parse kit SVGs into a typed scene model"
```
---
### Task 3: Geometry — speckle, union, curve fitting
**Files:**
- Create: `tools/svg/geometry.ts`
- Test: `tests/unit/svg-geometry.test.ts`
**Interfaces:**
- Produces:
```ts
export type Cubic = { c1: Point; c2: Point; to: Point };
export interface Contour { start: Point; segments: Cubic[] } // closed: last `to` returns to start
export function ringArea(ring: Ring): number; // signed
export function unionRings(rings: Ring[]): Ring[]; // outer rings CCW, holes CW (polygon-clipping orientation)
export function cornerIndices(ring: Ring, minTurnDeg: number): number[];
export function fitRing(ring: Ring, tolerance: number, minTurnDeg: number): Contour;
export function contourVertexCount(c: Contour): number; // 1 + segments.length
```
- [ ] **Step 1: Write the failing test**
`tests/unit/svg-geometry.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ringArea, unionRings, cornerIndices, fitRing, contourVertexCount } from '../../tools/svg/geometry.ts';
import type { Ring } from '../../tools/svg/model.ts';
const square: Ring = [[0, 0], [10, 0], [10, 10], [0, 10]];
test('ringArea is signed and sized', () => {
assert.equal(Math.abs(ringArea(square)), 100);
assert.equal(Math.abs(ringArea([[0, 0], [0.6, 0], [0.6, 0.6], [0, 0.6]])), 0.36);
});
test('unionRings merges touching rectangles into one ring', () => {
const merged = unionRings([square, [[10, 0], [20, 0], [20, 10], [10, 10]]]);
assert.equal(merged.length, 1);
assert.equal(Math.abs(ringArea(merged[0]!)), 200);
});
test('cornerIndices finds the four corners of a square and none on a dense circle', () => {
assert.deepEqual(cornerIndices(square, 60), [0, 1, 2, 3]);
const circle: Ring = Array.from({ length: 180 }, (_, i) => [50 + 40 * Math.cos(i * Math.PI / 90), 50 + 40 * Math.sin(i * Math.PI / 90)]);
assert.deepEqual(cornerIndices(circle, 60), []);
});
test('fitRing keeps square corners exactly and fits a 180-point circle in few cubics', () => {
const sq = fitRing(square, 0.75, 60);
assert.deepEqual(sq.start, [0, 0]);
assert.equal(sq.segments.length, 4);
const circle: Ring = Array.from({ length: 180 }, (_, i) => [50 + 40 * Math.cos(i * Math.PI / 90), 50 + 40 * Math.sin(i * Math.PI / 90)]);
const fitted = fitRing(circle, 0.75, 60);
assert.ok(contourVertexCount(fitted) <= 12, `expected ≤ 12 vertices, got ${contourVertexCount(fitted)}`);
for (const seg of fitted.segments) {
const r = Math.hypot(seg.to[0] - 50, seg.to[1] - 50);
assert.ok(Math.abs(r - 40) < 0.8, `endpoint drifted off the circle: ${r}`);
}
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/svg/geometry.ts'`
- [ ] **Step 3: Write `tools/svg/geometry.ts`**
```ts
import fitCurve from 'fit-curve';
import polygonClipping from 'polygon-clipping';
import type { Point, Ring } from './model.ts';
export type Cubic = { c1: Point; c2: Point; to: Point };
export interface Contour { start: Point; segments: Cubic[] }
export function ringArea(ring: Ring): number {
let area = 0;
for (let i = 0; i < ring.length; i++) {
const [x1, y1] = ring[i]!; const [x2, y2] = ring[(i + 1) % ring.length]!;
area += x1 * y2 - x2 * y1;
}
return area / 2;
}
/** Union of rings that share one paint. polygon-clipping returns exteriors and holes with opposite winding, which the nonzero fill rule renders correctly. */
export function unionRings(rings: Ring[]): Ring[] {
if (rings.length <= 1) return rings;
const result = polygonClipping.union(...rings.map(ring => [ring.map(p => [p[0], p[1]] as [number, number])]));
const out: Ring[] = [];
for (const polygon of result) for (const ring of polygon) {
const pts = ring.map(p => [p[0], p[1]] as Point);
if (pts.length > 1 && pts[0]![0] === pts[pts.length - 1]![0] && pts[0]![1] === pts[pts.length - 1]![1]) pts.pop();
if (pts.length >= 3) out.push(pts);
}
return out;
}
function turn(a: Point, b: Point, c: Point): number {
const v1 = [b[0] - a[0], b[1] - a[1]], v2 = [c[0] - b[0], c[1] - b[1]];
const l1 = Math.hypot(v1[0]!, v1[1]!), l2 = Math.hypot(v2[0]!, v2[1]!);
if (l1 === 0 || l2 === 0) return 0;
const cos = Math.min(1, Math.max(-1, (v1[0]! * v2[0]! + v1[1]! * v2[1]!) / (l1 * l2)));
return Math.acos(cos) * 180 / Math.PI;
}
/** Indices where the polyline turns more than `minTurnDeg`; those stay sharp through fitting. */
export function cornerIndices(ring: Ring, minTurnDeg: number): number[] {
const out: number[] = [];
const n = ring.length;
for (let i = 0; i < n; i++) {
const a = ring[(i - 1 + n) % n]!, b = ring[i]!, c = ring[(i + 1) % n]!;
if (turn(a, b, c) >= minTurnDeg) out.push(i);
}
return out;
}
function fitOpen(points: Point[], tolerance: number): Cubic[] {
if (points.length < 2) return [];
if (points.length === 2) { const [a, b] = points as [Point, Point]; return [{ c1: a, c2: b, to: b }]; }
const curves = fitCurve(points.map(p => [p[0], p[1]]), tolerance) as number[][][];
return curves.map(c => ({ c1: [c[1]![0]!, c[1]![1]!], c2: [c[2]![0]!, c[2]![1]!], to: [c[3]![0]!, c[3]![1]!] }));
}
/** Schneider fitting per corner-to-corner run. A ring with no corners is fitted as one closed run starting at index 0. */
export function fitRing(ring: Ring, tolerance: number, minTurnDeg: number): Contour {
const n = ring.length;
const corners = cornerIndices(ring, minTurnDeg);
const anchors = corners.length ? corners : [0];
const start = ring[anchors[0]!]!;
const segments: Cubic[] = [];
for (let k = 0; k < anchors.length; k++) {
const from = anchors[k]!, to = anchors[(k + 1) % anchors.length]!;
const run: Point[] = [];
let i = from;
do { run.push(ring[i]!); i = (i + 1) % n; } while (i !== to);
run.push(ring[to]!);
if (anchors.length === 1) run[run.length - 1] = ring[from]!; // closed run back to its own start
segments.push(...fitOpen(run, tolerance));
}
return { start, segments };
}
export function contourVertexCount(contour: Contour): number { return 1 + contour.segments.length; }
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test`
Expected: PASS. If `fit-curve` has no types, add `tools/svg/fit-curve.d.ts` containing `declare module 'fit-curve' { const fitCurve: (points: number[][], maxError: number) => number[][][]; export default fitCurve; }` and re-run.
- [ ] **Step 5: Commit**
```bash
git add tools/svg/geometry.ts tools/svg/fit-curve.d.ts tests/unit/svg-geometry.test.ts
git commit -m "Ring geometry: area, union, corner-aware cubic fitting"
```
---
### Task 4: Refine a scene and serialize it back
**Files:**
- Create: `tools/svg/refine.ts`, `tools/svg/serialize.ts`
- Test: `tests/unit/svg-refine.test.ts`
**Interfaces:**
- Produces:
```ts
export interface RefineOptions { minArea: number; tolerance: number; minTurnDeg: number; union: boolean }
export const DEFAULT_REFINE: RefineOptions; // { minArea: 4, tolerance: 0.75, minTurnDeg: 60, union: true }
export interface RefinedPath { kind: 'path'; id: string; fill: string; contours: Contour[] }
export interface RefinedGroup { kind: 'group'; id: string; label?: string; children: RefinedNode[] }
export type RefinedNode = RefinedPath | RefinedGroup;
export interface RefinedDoc { width: number; height: number; title: string; children: RefinedNode[] }
export interface RefineStats { before: { paths: number; rings: number; vertices: number }; after: { paths: number; contours: number; vertices: number }; dropped: { rings: number; paths: number } }
export function refineScene(doc: SceneDoc, options?: RefineOptions): { doc: RefinedDoc; stats: RefineStats };
export function serializeScene(doc: RefinedDoc): string; // compact SVG, ids kept, fill-rule nonzero
export function extractPart(doc: RefinedDoc, id: string): RefinedDoc; // same canvas, only that group
export function listGroupIds(doc: RefinedDoc): string[];
```
- [ ] **Step 1: Write the failing test**
`tests/unit/svg-refine.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { parseScene } from '../../tools/svg/parse.ts';
import { refineScene, serializeScene, extractPart, listGroupIds } from '../../tools/svg/refine.ts';
const doc = parseScene(readFileSync(new URL('../fixtures/mini-scene.svg', import.meta.url), 'utf8'));
test('refineScene drops speckle, keeps ids and fills, reports stats', () => {
const { doc: refined, stats } = refineScene(doc);
assert.equal(stats.before.rings, 4);
assert.equal(stats.dropped.rings, 1); // the 0.36 px² speck
assert.equal(stats.after.paths, 3);
assert.deepEqual(listGroupIds(refined), ['cleanplates', 'door_it', 'door_it_leaf']);
const leaf = (refined.children[1] as any).children[0];
assert.equal(leaf.children[0].id, 'door_it_leaf__p00001');
assert.equal(leaf.children[0].fill, '#3b2a6e');
assert.equal(leaf.children[0].contours.length, 1);
});
test('serializeScene writes cubic path data with ids and no raster/filter/text', () => {
const svg = serializeScene(refineScene(doc).doc);
assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" width="100" height="50" viewBox="0 0 100 50">/);
assert.match(svg, /<g id="door_it_leaf">/);
assert.match(svg, /<path id="door_it_leaf__p00001" fill="#3b2a6e" d="M10 10C/);
assert.doesNotMatch(svg, /<image|<filter|<text|inkscape/);
});
test('extractPart keeps only the named group on the same canvas', () => {
const part = extractPart(refineScene(doc).doc, 'door_it_leaf');
assert.equal(part.width, 100);
assert.deepEqual(listGroupIds(part), ['door_it_leaf']);
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/svg/refine.ts'`
- [ ] **Step 3: Write `tools/svg/refine.ts`**
```ts
import type { GroupNode, PathNode, SceneDoc, SvgNode } from './model.ts';
import { countRings } from './parse.ts';
import { fitRing, ringArea, unionRings, type Contour } from './geometry.ts';
export { serializeScene, extractPart, listGroupIds } from './serialize.ts';
export interface RefineOptions { minArea: number; tolerance: number; minTurnDeg: number; union: boolean }
export const DEFAULT_REFINE: RefineOptions = { minArea: 4, tolerance: 0.75, minTurnDeg: 60, union: true };
export interface RefinedPath { kind: 'path'; id: string; fill: string; contours: Contour[] }
export interface RefinedGroup { kind: 'group'; id: string; label?: string; children: RefinedNode[] }
export type RefinedNode = RefinedPath | RefinedGroup;
export interface RefinedDoc { width: number; height: number; title: string; children: RefinedNode[] }
export interface RefineStats {
before: { paths: number; rings: number; vertices: number };
after: { paths: number; contours: number; vertices: number };
dropped: { rings: number; paths: number };
}
function refinePath(path: PathNode, options: RefineOptions, stats: RefineStats): RefinedPath | null {
const kept = path.rings.filter(ring => Math.abs(ringArea(ring)) >= options.minArea);
stats.dropped.rings += path.rings.length - kept.length;
if (kept.length === 0) { stats.dropped.paths++; return null; }
const rings = options.union ? unionRings(kept) : kept;
const contours = rings.map(ring => fitRing(ring, options.tolerance, options.minTurnDeg));
stats.after.paths++;
stats.after.contours += contours.length;
for (const contour of contours) stats.after.vertices += 1 + contour.segments.length;
return { kind: 'path', id: path.id, fill: path.fill, contours };
}
function refineNodes(nodes: SvgNode[], options: RefineOptions, stats: RefineStats): RefinedNode[] {
const out: RefinedNode[] = [];
for (const node of nodes) {
if (node.kind === 'path') { const refined = refinePath(node, options, stats); if (refined) out.push(refined); continue; }
const group: RefinedGroup = { kind: 'group', id: node.id, children: refineNodes(node.children, options, stats) };
if (node.label) group.label = node.label;
out.push(group); // empty groups stay: the manifest names them and Rive nodes may need them as pivots
}
return out;
}
/** Speckle drop → same-paint union → corner-aware cubic fit. Ids, labels, fills and tree order are untouched. */
export function refineScene(doc: SceneDoc, options: RefineOptions = DEFAULT_REFINE): { doc: RefinedDoc; stats: RefineStats } {
const stats: RefineStats = { before: countRings(doc), after: { paths: 0, contours: 0, vertices: 0 }, dropped: { rings: 0, paths: 0 } };
const children = refineNodes(doc.children, options, stats);
return { doc: { width: doc.width, height: doc.height, title: doc.title, children }, stats };
}
```
`tools/svg/serialize.ts`:
```ts
import type { Contour } from './geometry.ts';
import type { RefinedDoc, RefinedGroup, RefinedNode } from './refine.ts';
const fmt = (n: number) => { const r = Math.round(n * 10) / 10; return Object.is(r, -0) ? '0' : String(r); };
export function contourToPathData(contour: Contour): string {
let d = `M${fmt(contour.start[0])} ${fmt(contour.start[1])}`;
for (const s of contour.segments) d += `C${fmt(s.c1[0])} ${fmt(s.c1[1])} ${fmt(s.c2[0])} ${fmt(s.c2[1])} ${fmt(s.to[0])} ${fmt(s.to[1])}`;
return d + 'Z';
}
function node(n: RefinedNode): string {
if (n.kind === 'path') return `<path id="${n.id}" fill="${n.fill}" d="${n.contours.map(contourToPathData).join('')}"/>`;
return `<g id="${n.id}">${n.children.map(node).join('')}</g>`;
}
/** Plain SVG 1.1, no namespaces beyond svg, no styles, nonzero fill rule (the default). */
export function serializeScene(doc: RefinedDoc): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${doc.width}" height="${doc.height}" viewBox="0 0 ${doc.width} ${doc.height}"><title>${doc.title}</title>${doc.children.map(node).join('')}</svg>`;
}
function find(nodes: RefinedNode[], id: string): RefinedGroup | null {
for (const n of nodes) {
if (n.kind !== 'group') continue;
if (n.id === id) return n;
const inner = find(n.children, id); if (inner) return inner;
}
return null;
}
export function extractPart(doc: RefinedDoc, id: string): RefinedDoc {
const group = find(doc.children, id);
if (!group) throw new Error(`part ${id} not found`);
return { width: doc.width, height: doc.height, title: `${doc.title} — ${id}`, children: [group] };
}
export function listGroupIds(doc: RefinedDoc): string[] {
const out: string[] = [];
const walk = (nodes: RefinedNode[]) => { for (const n of nodes) if (n.kind === 'group') { out.push(n.id); walk(n.children); } };
walk(doc.children);
return out;
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test`
Expected: PASS (all tests so far)
- [ ] **Step 5: Measure on the real lobby**
Run: `node --import tsx -e "import {readFileSync} from 'node:fs'; import {parseScene} from './tools/svg/parse.ts'; import {refineScene,serializeScene} from './tools/svg/refine.ts'; import {resolveRoom} from './tools/kits.ts'; const t=Date.now(); const r=refineScene(parseScene(readFileSync(resolveRoom('lobby').svg,'utf8'))); console.log(r.stats, 'svg bytes', serializeScene(r.doc).length, 'ms', Date.now()-t);"`
Expected: `before.vertices 390849`, `after.vertices` ≤ 78,000 (≥ 5× fewer), under 60 s. If `after.vertices` is above 78,000, raise `tolerance` to 1.0 in `DEFAULT_REFINE` and re-run; record the final numbers in the commit message.
- [ ] **Step 6: Commit**
```bash
git add tools/svg/refine.ts tools/svg/serialize.ts tests/unit/svg-refine.test.ts
git commit -m "Refine traced scenes: speckle drop, union, cubic fit; serialize and part extraction"
```
---
### Task 5: Reference diff and the `refine` CLI
**Files:**
- Create: `tools/svg/diff.ts`, `tools/refine-vectors.ts`
- Test: `tests/unit/refine-cli.test.ts`
**Interfaces:**
- Produces:
```ts
export interface DiffResult { width: number; height: number; differentPixels: number; ratio: number }
export function renderSvg(svg: string, width: number): Uint8Array; // PNG bytes (resvg)
export function diffAgainstReference(svg: string, referencePng: string, outPng: string): DiffResult;
export interface RefinedManifest {
room: string; canvas: { width: number; height: number };
source: { svg: string; sha256: string; manifest: string; hotspots: string };
stats: RefineStats; options: RefineOptions;
parts: Array<{ id: string; file: string; pivot: [number, number]; motion: string }>;
hotspots: unknown; // the kit's hotspots.json verbatim
diff: DiffResult;
}
export function refineRoom(room: RoomId, options?: RefineOptions): RefinedManifest; // writes scenes/refined/<room>/
```
Parts are: every `doors[].leafId` from the kit manifest plus every `layers[]` entry whose `motion` is not `static` and not `parallax_small`.
- [ ] **Step 1: Write the failing test**
`tests/unit/refine-cli.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { diffAgainstReference, renderSvg } from '../../tools/svg/diff.ts';
import { selectParts } from '../../tools/refine-vectors.ts';
test('renderSvg produces a PNG of the requested width', () => {
const png = renderSvg('<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10" viewBox="0 0 20 10"><rect width="20" height="10" fill="#fff"/></svg>', 40);
assert.equal(png[0], 0x89); assert.equal(png[1], 0x50);
});
test('diffAgainstReference reports zero difference for an identical render', () => {
const dir = mmkd();
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10" viewBox="0 0 20 10"><rect width="20" height="10" fill="#4a2f8a"/></svg>';
const reference = join(dir, 'ref.png'); require('node:fs').writeFileSync(reference, renderSvg(svg, 20));
const result = diffAgainstReference(svg, reference, join(dir, 'diff.png'));
assert.equal(result.differentPixels, 0); assert.equal(result.ratio, 0);
assert.ok(existsSync(join(dir, 'diff.png')));
});
test('selectParts picks door leaves and moving layers from a kit manifest', () => {
const manifest = { doors: [{ id: 'it', leafId: 'door_it_leaf', pivot: [564, 529] }], layers: [
{ id: 'architecture_back_wall', pivot: [0, 0], motion: 'parallax_small' },
{ id: 'plant_left', pivot: [400, 500], motion: 'sway' },
{ id: 'door_it_frame', pivot: [0, 0], motion: 'static' },
] };
assert.deepEqual(selectParts(manifest as any), [
{ id: 'door_it_leaf', pivot: [564, 529], motion: 'door' },
{ id: 'plant_left', pivot: [400, 500], motion: 'sway' },
]);
});
function mmkd() { return mkdtempSync(join(tmpdir(), 'tdg-refine-')); }
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/svg/diff.ts'`
- [ ] **Step 3: Write `tools/svg/diff.ts`**
```ts
import { readFileSync, writeFileSync } from 'node:fs';
import { Resvg } from '@resvg/resvg-js';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
export interface DiffResult { width: number; height: number; differentPixels: number; ratio: number }
export function renderSvg(svg: string, width: number): Uint8Array {
return new Resvg(svg, { fitTo: { mode: 'width', value: width }, background: 'white' }).render().asPng();
}
/** Renders the SVG at the reference's own size and counts pixels that differ by more than pixelmatch's 0.1 threshold. */
export function diffAgainstReference(svg: string, referencePng: string, outPng: string): DiffResult {
const reference = PNG.sync.read(readFileSync(referencePng));
const rendered = PNG.sync.read(Buffer.from(renderSvg(svg, reference.width)));
if (rendered.height !== reference.height) throw new Error(`render is ${rendered.width}×${rendered.height}, reference is ${reference.width}×${reference.height}`);
const diff = new PNG({ width: reference.width, height: reference.height });
const differentPixels = pixelmatch(rendered.data, reference.data, diff.data, reference.width, reference.height, { threshold: 0.1, includeAA: false });
writeFileSync(outPng, PNG.sync.write(diff));
return { width: reference.width, height: reference.height, differentPixels, ratio: differentPixels / (reference.width * reference.height) };
}
```
- [ ] **Step 4: Write `tools/refine-vectors.ts`**
```ts
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { loadKits, repoRoot, resolveRoom, type RoomId } from './kits.ts';
import { parseScene } from './svg/parse.ts';
import { DEFAULT_REFINE, extractPart, refineScene, serializeScene, type RefineOptions, type RefineStats } from './svg/refine.ts';
import { diffAgainstReference, type DiffResult } from './svg/diff.ts';
export interface KitManifest { doors?: Array<{ id: string; leafId: string; pivot: [number, number] }>; layers: Array<{ id: string; pivot: [number, number]; motion: string }> }
export interface PartSpec { id: string; pivot: [number, number]; motion: string }
export interface RefinedManifest {
room: RoomId; canvas: { width: number; height: number };
source: { svg: string; sha256: string; manifest: string; hotspots: string };
stats: RefineStats; options: RefineOptions;
parts: PartSpec[]; hotspots: unknown; diff: DiffResult;
}
const STATIC = new Set(['static', 'parallax_small']);
/** Door leaves first (motion "door"), then every layer the kit marks as moving. */
export function selectParts(manifest: KitManifest): PartSpec[] {
const parts: PartSpec[] = (manifest.doors ?? []).map(door => ({ id: door.leafId, pivot: door.pivot, motion: 'door' }));
const seen = new Set(parts.map(p => p.id));
for (const layer of manifest.layers) if (!STATIC.has(layer.motion) && !seen.has(layer.id)) { parts.push({ id: layer.id, pivot: layer.pivot, motion: layer.motion }); seen.add(layer.id); }
return parts;
}
export function refineRoom(room: RoomId, options: RefineOptions = DEFAULT_REFINE): RefinedManifest {
const kits = loadKits();
const kit = resolveRoom(room, kits);
const out = resolve(repoRoot, 'scenes/refined', room);
mkdirSync(join(out, 'parts'), { recursive: true });
const svgText = readFileSync(kit.svg, 'utf8');
const kitManifest = JSON.parse(readFileSync(kit.manifest, 'utf8')) as KitManifest;
const hotspots = JSON.parse(readFileSync(kit.hotspots, 'utf8')) as unknown;
const { doc, stats } = refineScene(parseScene(svgText), options);
const scene = serializeScene(doc);
writeFileSync(join(out, 'scene.svg'), scene);
const parts = selectParts(kitManifest).filter(part => { try { extractPart(doc, part.id); return true; } catch { console.warn(`part ${part.id} not in SVG; skipped`); return false; } });
for (const part of parts) writeFileSync(join(out, 'parts', `${part.id}.svg`), serializeScene(extractPart(doc, part.id)));
const diff = diffAgainstReference(scene, kit.reference, join(out, 'diff.png'));
const manifest: RefinedManifest = {
room, canvas: kits.canvas,
source: { svg: relative(kits.root, kit.svg), sha256: createHash('sha256').update(svgText).digest('hex'), manifest: relative(kits.root, kit.manifest), hotspots: relative(kits.root, kit.hotspots) },
stats, options, parts, hotspots, diff,
};
writeFileSync(join(out, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
return manifest;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const index = process.argv.indexOf('--room');
const room = (index > 0 ? process.argv[index + 1] : 'lobby') as RoomId;
const t = Date.now();
const m = refineRoom(room);
console.log(`${room}: ${m.stats.before.vertices} → ${m.stats.after.vertices} vertices (${(m.stats.before.vertices / m.stats.after.vertices).toFixed(1)}×), ${m.stats.after.paths} paths, ${m.parts.length} parts, diff ${(m.diff.ratio * 100).toFixed(2)}% of pixels, ${Date.now() - t} ms`);
}
```
- [ ] **Step 5: Run the tests, then the CLI on the lobby**
Run: `npm test`
Expected: PASS
Run: `npm run refine -- --room lobby && ls -la scenes/refined/lobby scenes/refined/lobby/parts && open scenes/refined/lobby/diff.png`
Expected: `scene.svg` (well under 2 MB), `parts/door_it_leaf.svg`, `parts/door_government_leaf.svg`, `parts/door_cyber_leaf.svg` plus the moving layers, `manifest.json`, `diff.png`; printed diff ratio ≤ 6% (banding at gradient edges is expected; door outlines and lettering must not appear as solid red in `diff.png`). Look at `scene.svg` in Chrome (`open -a "Google Chrome" scenes/refined/lobby/scene.svg`) next to the reference PNG.
- [ ] **Step 6: Commit**
```bash
git add tools/svg/diff.ts tools/refine-vectors.ts tests/unit/refine-cli.test.ts scenes/refined/lobby
git commit -m "Refine CLI with reference diff; lobby refined"
```
---
### Task 6: Rive editor client
**Files:**
- Create: `tools/rive-mcp.ts`
- Test: `tests/unit/rive-mcp.test.ts`
**Interfaces:**
- Produces:
```ts
export interface ToolResult { text?: unknown; images: Buffer[]; isError: boolean }
export class RiveEditor {
constructor(url?: string, log?: (entry: LogEntry) => void); // default http://127.0.0.1:9791/mcp
initialize(): Promise<{ name: string; version: string }>;
call<T = any>(name: string, args?: Record<string, unknown>): Promise<T>; // parsed JSON text content; throws on isError
callRaw(name: string, args?: Record<string, unknown>): Promise<ToolResult>;
uploadSvg(filePath: string, name: string): Promise<string>; // asset id (data-URI upload)
capture(artboard: string, outPng: string, longEdge?: number): Promise<void>;
exportRiv(outDir: string): Promise<{ path: string; bytes: number }>; // inline_base64; writes <outDir>/<filename>
}
export interface LogEntry { at: string; tool: string; args: unknown; ok: boolean; ms: number; summary: string }
```
- [ ] **Step 1: Write the failing test (against a stub server)**
`tests/unit/rive-mcp.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { RiveEditor } from '../../tools/rive-mcp.ts';
function stub(handler: (req: any) => any) {
const server = createServer((req, res) => {
let body = ''; req.on('data', c => body += c); req.on('end', () => {
const rpc = JSON.parse(body);
const result = handler(rpc);
res.setHeader('content-type', 'text/event-stream');
res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: rpc.id, result })}\n\n`);
});
});
return new Promise<{ url: string; close: () => void }>(resolve => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${(server.address() as any).port}/mcp`, close: () => server.close() })));
}
test('call parses SSE-framed JSON text content and throws on isError', async () => {
const seen: any[] = [];
const s = await stub(rpc => {
seen.push(rpc);
if (rpc.method === 'initialize') return { serverInfo: { name: 'rive', version: '0.6' } };
if (rpc.params.name === 'bad') return { isError: true, content: [{ type: 'text', text: 'Error: nope' }] };
return { content: [{ type: 'text', text: JSON.stringify({ success: true, echo: rpc.params.arguments }) }] };
});
const editor = new RiveEditor(s.url);
assert.deepEqual(await editor.initialize(), { name: 'rive', version: '0.6' });
assert.deepEqual(await editor.call('list_artboards', { a: 1 }), { success: true, echo: { a: 1 } });
await assert.rejects(editor.call('bad'), /nope/);
assert.equal(seen[1].params.name, 'list_artboards');
s.close();
});
test('uploadSvg sends a data URI and exportRiv writes decoded bytes', async () => {
const dir = mkdtempSync(join(tmpdir(), 'tdg-mcp-'));
writeFileSync(join(dir, 'x.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>');
let uploaded = '';
const s = await stub(rpc => {
if (rpc.method === 'initialize') return { serverInfo: { name: 'rive', version: '0.6' } };
if (rpc.params.name === 'upload_asset') { uploaded = rpc.params.arguments.file; return { content: [{ type: 'text', text: JSON.stringify({ success: true, asset: { id: '0-14' } }) }] }; }
if (rpc.params.name === 'export_file') return { content: [{ type: 'text', text: JSON.stringify({ success: true, filename: 'x.riv', data: Buffer.from('RIVE').toString('base64') }) }] };
throw new Error('unexpected');
});
const editor = new RiveEditor(s.url);
assert.equal(await editor.uploadSvg(join(dir, 'x.svg'), 'x'), '0-14');
assert.match(uploaded, /^data:image\/svg\+xml;name=x\.svg;base64,/);
const out = await editor.exportRiv(dir);
assert.equal(readFileSync(out.path, 'utf8'), 'RIVE'); assert.equal(out.bytes, 4);
s.close();
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/rive-mcp.ts'`
- [ ] **Step 3: Write `tools/rive-mcp.ts`**
```ts
import { readFileSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
export interface ToolResult { text?: unknown; images: Buffer[]; isError: boolean }
export interface LogEntry { at: string; tool: string; args: unknown; ok: boolean; ms: number; summary: string }
/** JSON-RPC client for the Rive Early Access editor MCP. The editor is sandboxed: files go in as data URIs and come out as inline base64. */
export class RiveEditor {
private id = 0;
private sessionId: string | null = null;
constructor(private readonly url = 'http://127.0.0.1:9791/mcp', private readonly log: (entry: LogEntry) => void = () => {}) {}
private async rpc(method: string, params: Record<string, unknown>): Promise<any> {
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' };
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
const response = await fetch(this.url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: ++this.id, method, params }), signal: AbortSignal.timeout(180_000) });
const session = response.headers.get('mcp-session-id'); if (session) this.sessionId = session;
const raw = await response.text();
const lines = raw.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trim());
const body = JSON.parse(lines.length ? lines[lines.length - 1]! : raw);
if (body.error) throw new Error(`${method}: ${body.error.message ?? JSON.stringify(body.error)}`);
return body.result;
}
async initialize(): Promise<{ name: string; version: string }> {
const result = await this.rpc('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'thatdeveloperguy-website', version: '0.1.0' } });
return { name: result.serverInfo.name, version: result.serverInfo.version };
}
async callRaw(name: string, args: Record<string, unknown> = {}): Promise<ToolResult> {
const t = Date.now();
const result = await this.rpc('tools/call', { name, arguments: args });
const out: ToolResult = { images: [], isError: Boolean(result.isError) };
for (const item of result.content ?? []) {
if (item.type === 'image') out.images.push(Buffer.from(item.data, 'base64'));
if (item.type === 'text') { try { out.text = JSON.parse(item.text); } catch { out.text = item.text; } }
}
const summary = typeof out.text === 'string' ? out.text.slice(0, 160) : JSON.stringify(out.text ?? '').slice(0, 160);
this.log({ at: new Date().toISOString(), tool: name, args: name === 'upload_asset' ? { ...args, file: '<data-uri>' } : args, ok: !out.isError, ms: Date.now() - t, summary });
return out;
}
async call<T = any>(name: string, args: Record<string, unknown> = {}): Promise<T> {
const result = await this.callRaw(name, args);
if (result.isError) throw new Error(`${name}: ${typeof result.text === 'string' ? result.text : JSON.stringify(result.text)}`);
if (result.text && typeof result.text === 'object' && (result.text as any).success === false) throw new Error(`${name}: ${JSON.stringify(result.text)}`);
return result.text as T;
}
async uploadSvg(filePath: string, name: string): Promise<string> {
const data = readFileSync(filePath).toString('base64');
const file = `data:image/svg+xml;name=${encodeURIComponent(name)}.svg;base64,${data}`;
const result = await this.call<{ asset: { id: string } }>('upload_asset', { file, name });
return result.asset.id;
}
async capture(artboard: string, outPng: string, longEdge = 1536): Promise<void> {
const result = await this.callRaw('capture_artboard', { artboardId: artboard, longEdge, backgroundColor: '#ffffff' });
if (result.isError || !result.images[0]) throw new Error(`capture_artboard: ${JSON.stringify(result.text)}`);
writeFileSync(outPng, result.images[0]);
}
async exportRiv(outDir: string): Promise<{ path: string; bytes: number }> {
const result = await this.call<{ filename: string; data?: string; path?: string }>('export_file', { format: 'riv', destination: outDir, inline_base64: true });
if (!result.data) { const bytes = readFileSync(result.path!); const path = join(outDir, basename(result.path!)); writeFileSync(path, bytes); return { path, bytes: bytes.length }; }
const bytes = Buffer.from(result.data, 'base64');
const path = join(outDir, result.filename);
writeFileSync(path, bytes);
return { path, bytes: bytes.length };
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Live smoke against the editor**
Run: `node --import tsx -e "import {RiveEditor} from './tools/rive-mcp.ts'; const e=new RiveEditor(); console.log(await e.initialize()); console.log(await e.call('session_info'));"`
Expected: `{ name: 'rive', version: '0.6' }` and the open file's name. If it fails, Rive Early Access is not running or the MCP is off — stop and say so.
- [ ] **Step 6: Commit**
```bash
git add tools/rive-mcp.ts tests/unit/rive-mcp.test.ts
git commit -m "Rive editor MCP client: data-URI upload, capture, inline export"
```
---
### Task 7: Lobby authoring plan (pure) and executor
**Files:**
- Create: `tools/rive-plan.ts`, `tools/rive-author.ts`
- Test: `tests/unit/rive-plan.test.ts`
**Interfaces:**
- Consumes: `RefinedManifest` from `scenes/refined/lobby/manifest.json`, `RiveEditor`.
- Produces:
```ts
export interface LobbyPlan {
artboard: 'Lobby'; width: 1648; height: 928;
scene: { file: 'scene.svg'; nodeName: 'scene' };
doors: Array<{ id: 'it' | 'government' | 'cyber'; leafId: string; hinge: [number, number]; hit: { x: number; y: number; width: number; height: number }; href: string; label: string }>;
enum: { name: 'LobbyFocus'; values: ['none', 'it', 'government', 'cyber'] };
viewModel: { name: 'Lobby'; properties: Array<{ name: string; type: 'enum' | 'trigger' | 'boolean' }> };
animations: Array<{ name: string; durationFrames: number; loop: 'oneShot' | 'loop' | 'pingPong'; keys: Array<{ target: 'hinge' | 'light' | 'leaf'; door?: string; property: 'scaleX' | 'y' | 'opacity' | 'scaleY'; frame: number; value: number; interpolation: 'cubic' | 'linear' | 'hold' }> }>;
layers: Array<{ name: string; states: Array<{ name: string; animation?: string }>; transitions: Array<{ from: string; to: string; when?: { property: string; equals?: string | boolean } }> }>;
}
export function planLobby(manifest: RefinedManifest): LobbyPlan;
```
`rive-manifest.json` written by the executor:
```json
{ "room": "lobby", "file": "lobby.riv", "sha256": "…", "bytes": 0, "artboard": "Lobby", "stateMachine": "State Machine 1",
"viewModel": "Lobby", "enum": { "LobbyFocus": ["none","it","government","cyber"] },
"inputs": { "focus": "enum", "openIt": "trigger", "openGovernment": "trigger", "openCyber": "trigger", "reducedMotion": "boolean" },
"animations": ["Ambient","DoorOpenIt","DoorOpenGovernment","DoorOpenCyber","FocusIt","FocusGovernment","FocusCyber"],
"editor": { "fileId": 0, "url": "" }, "authoredAt": "" }
```
- [ ] **Step 1: Write the failing test**
`tests/unit/rive-plan.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { planLobby } from '../../tools/rive-plan.ts';
const manifest = JSON.parse(readFileSync(new URL('../../scenes/refined/lobby/manifest.json', import.meta.url), 'utf8'));
test('planLobby derives three doors with hinges, hits and routes from the kit hotspots', () => {
const plan = planLobby(manifest);
assert.equal(plan.artboard, 'Lobby');
assert.deepEqual(plan.doors.map(d => d.id), ['it', 'government', 'cyber']);
assert.deepEqual(plan.doors[0], { id: 'it', leafId: 'door_it_leaf', hinge: [564, 529], hit: { x: 542, y: 229, width: 176, height: 302 }, href: '/it-services/', label: 'IT SERVICES' });
});
test('planLobby names every animation, view-model property and state-machine layer the site relies on', () => {
const plan = planLobby(manifest);
assert.deepEqual(plan.animations.map(a => a.name), ['Ambient', 'DoorOpenIt', 'DoorOpenGovernment', 'DoorOpenCyber', 'FocusIt', 'FocusGovernment', 'FocusCyber']);
assert.deepEqual(plan.viewModel.properties, [
{ name: 'focus', type: 'enum' }, { name: 'openIt', type: 'trigger' }, { name: 'openGovernment', type: 'trigger' }, { name: 'openCyber', type: 'trigger' }, { name: 'reducedMotion', type: 'boolean' },
]);
assert.deepEqual(plan.layers.map(l => l.name), ['Ambient', 'DoorIt', 'DoorGovernment', 'DoorCyber', 'Focus']);
const door = plan.layers[1]!;
assert.deepEqual(door.transitions, [{ from: 'Closed', to: 'Open', when: { property: 'openIt' } }]);
const open = plan.animations.find(a => a.name === 'DoorOpenIt')!;
assert.equal(open.loop, 'oneShot');
assert.deepEqual(open.keys[0], { target: 'hinge', door: 'it', property: 'scaleX', frame: 0, value: 100, interpolation: 'cubic' });
assert.deepEqual(open.keys[1], { target: 'hinge', door: 'it', property: 'scaleX', frame: 26, value: 22, interpolation: 'cubic' });
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/rive-plan.ts'`
- [ ] **Step 3: Write `tools/rive-plan.ts`**
```ts
import type { RefinedManifest } from './refine-vectors.ts';
type DoorId = 'it' | 'government' | 'cyber';
const TITLE: Record<DoorId, string> = { it: 'It', government: 'Government', cyber: 'Cyber' };
interface KitDoor { id: DoorId; label: string; href: string; leafId: string; pivot: [number, number]; hitbox: { x: number; y: number; width: number; height: number } }
export interface PlanKey { target: 'hinge' | 'light' | 'leaf'; door?: DoorId; property: 'scaleX' | 'y' | 'opacity' | 'scaleY'; frame: number; value: number; interpolation: 'cubic' | 'linear' | 'hold' }
export interface PlanAnimation { name: string; durationFrames: number; loop: 'oneShot' | 'loop' | 'pingPong'; keys: PlanKey[] }
export interface PlanLayer { name: string; states: Array<{ name: string; animation?: string }>; transitions: Array<{ from: string; to: string; when?: { property: string; equals?: string | boolean } }> }
export interface LobbyPlan {
artboard: 'Lobby'; width: 1648; height: 928;
scene: { file: 'scene.svg'; nodeName: 'scene' };
doors: Array<{ id: DoorId; leafId: string; hinge: [number, number]; hit: { x: number; y: number; width: number; height: number }; href: string; label: string }>;
enum: { name: 'LobbyFocus'; values: ['none', 'it', 'government', 'cyber'] };
viewModel: { name: 'Lobby'; properties: Array<{ name: string; type: 'enum' | 'trigger' | 'boolean' }> };
animations: PlanAnimation[];
layers: PlanLayer[];
}
/** The lobby's motion vocabulary. Door = 2-D hinge compression toward the kit pivot over 26 frames; focus = 4 px lift + status light swell; ambient = lights breathe. */
export function planLobby(manifest: RefinedManifest): LobbyPlan {
const hotspots = manifest.hotspots as { doors: KitDoor[] };
const doors = hotspots.doors.map(door => ({ id: door.id, leafId: door.leafId, hinge: door.pivot, hit: door.hitbox, href: door.href, label: door.label }));
const animations: PlanAnimation[] = [
{ name: 'Ambient', durationFrames: 180, loop: 'pingPong', keys: doors.map(d => ({ target: 'light' as const, door: d.id, property: 'opacity' as const, frame: 0, value: 70, interpolation: 'cubic' as const })).concat(doors.map(d => ({ target: 'light' as const, door: d.id, property: 'opacity' as const, frame: 180, value: 100, interpolation: 'cubic' as const }))) },
...doors.map(d => ({ name: `DoorOpen${TITLE[d.id]}`, durationFrames: 26, loop: 'oneShot' as const, keys: [
{ target: 'hinge' as const, door: d.id, property: 'scaleX' as const, frame: 0, value: 100, interpolation: 'cubic' as const },
{ target: 'hinge' as const, door: d.id, property: 'scaleX' as const, frame: 26, value: 22, interpolation: 'cubic' as const },
{ target: 'leaf' as const, door: d.id, property: 'opacity' as const, frame: 0, value: 100, interpolation: 'linear' as const },
{ target: 'leaf' as const, door: d.id, property: 'opacity' as const, frame: 26, value: 82, interpolation: 'linear' as const },
] })),
...doors.map(d => ({ name: `Focus${TITLE[d.id]}`, durationFrames: 12, loop: 'oneShot' as const, keys: [
{ target: 'hinge' as const, door: d.id, property: 'y' as const, frame: 0, value: d.hinge[1], interpolation: 'cubic' as const },
{ target: 'hinge' as const, door: d.id, property: 'y' as const, frame: 12, value: d.hinge[1] - 4, interpolation: 'cubic' as const },
{ target: 'light' as const, door: d.id, property: 'scaleX' as const, frame: 0, value: 100, interpolation: 'cubic' as const },
{ target: 'light' as const, door: d.id, property: 'scaleX' as const, frame: 12, value: 130, interpolation: 'cubic' as const },
{ target: 'light' as const, door: d.id, property: 'scaleY' as const, frame: 0, value: 100, interpolation: 'cubic' as const },
{ target: 'light' as const, door: d.id, property: 'scaleY' as const, frame: 12, value: 130, interpolation: 'cubic' as const },
] })),
];
const layers: PlanLayer[] = [
{ name: 'Ambient', states: [{ name: 'Ambient', animation: 'Ambient' }], transitions: [{ from: '{Entry State}', to: 'Ambient' }] },
...doors.map(d => ({ name: `Door${TITLE[d.id]}`, states: [{ name: 'Closed' }, { name: 'Open', animation: `DoorOpen${TITLE[d.id]}` }], transitions: [{ from: 'Closed', to: 'Open', when: { property: `open${TITLE[d.id]}` } }] })),
{ name: 'Focus', states: [{ name: 'None' }, ...doors.map(d => ({ name: `Focus${TITLE[d.id]}`, animation: `Focus${TITLE[d.id]}` }))], transitions: [
...doors.map(d => ({ from: '{Any State}', to: `Focus${TITLE[d.id]}`, when: { property: 'focus', equals: d.id } })),
{ from: '{Any State}', to: 'None', when: { property: 'focus', equals: 'none' } },
] },
];
return {
artboard: 'Lobby', width: 1648, height: 928, scene: { file: 'scene.svg', nodeName: 'scene' }, doors,
enum: { name: 'LobbyFocus', values: ['none', 'it', 'government', 'cyber'] },
viewModel: { name: 'Lobby', properties: [{ name: 'focus', type: 'enum' }, ...doors.map(d => ({ name: `open${TITLE[d.id]}`, type: 'trigger' as const })), { name: 'reducedMotion', type: 'boolean' }] },
animations, layers,
};
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Write `tools/rive-author.ts` (the executor)**
```ts
/**
* Authors the lobby artboard in the open Rive Early Access file and exports it.
* npm run rive:author -- --room lobby [--replace]
* Every editor call is appended to scenes/rive/lobby/authoring-log.json.
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repoRoot } from './kits.ts';
import { RiveEditor, type LogEntry } from './rive-mcp.ts';
import { planLobby, type LobbyPlan } from './rive-plan.ts';
import type { RefinedManifest } from './refine-vectors.ts';
const TITLE = { it: 'It', government: 'Government', cyber: 'Cyber' } as const;
const LIGHT_ID = { it: 'door_it_status_light', government: 'door_government_status_light', cyber: 'door_cyber_status_light' } as const;
interface Ctx { editor: RiveEditor; plan: LobbyPlan; artboardId: string; ids: Map<string, string>; keys: Map<string, Record<string, number>>; vm: { id: string; props: Record<string, string> }; animations: Record<string, string>; stateMachineId: string; stateMachineName: string }
async function propertyKey(ctx: Ctx, objectId: string, name: string): Promise<number> {
if (!ctx.keys.has(objectId)) {
const result = await ctx.editor.call<{ properties: Record<string, Record<string, number>> | Record<string, number> }>('query_property_keys', { objectIds: [objectId] });
const table = (result.properties as any)[objectId] ?? result.properties;
ctx.keys.set(objectId, table as Record<string, number>);
}
const table = ctx.keys.get(objectId)!;
const wanted = name.toLowerCase().replace(/[^a-z]/g, '');
for (const [label, key] of Object.entries(table)) if (label.toLowerCase().replace(/[^a-z]/g, '') === wanted) return key;
throw new Error(`property ${name} not found on ${objectId}: ${Object.keys(table).join(', ')}`);
}
async function findId(ctx: Ctx, name: string): Promise<string> {
const result = await ctx.editor.call<{ objects: Array<{ id: string; name: string }> }>('find_objects', { name });
const exact = result.objects.find(o => o.name === name);
if (!exact) throw new Error(`object ${name} not found in artboard`);
return exact.id;
}
/** Wrap a leaf in a hinge group at the kit pivot so scaleX compresses toward the hinge. */
async function hinge(ctx: Ctx, door: LobbyPlan['doors'][number]): Promise<void> {
const leafId = await findId(ctx, door.leafId);
const group = await ctx.editor.call<{ id?: string; nodeId?: string; group?: { id: string } }>('group_editor', { name: `hinge_${door.id}`, x: door.hinge[0], y: door.hinge[1] });
const hingeId = group.id ?? group.nodeId ?? group.group!.id;
await ctx.editor.call('reparent_objects', { operations: [{ objectId: leafId, newParentId: hingeId, position: 'start' }] });
const xKey = await propertyKey(ctx, leafId, 'x'), yKey = await propertyKey(ctx, leafId, 'y');
const current = await ctx.editor.call<{ values: Record<string, Record<string, number>> }>('query_property_values', { objectIds: [leafId], propertyKeys: [xKey, yKey] });
const values = (current.values as any)[leafId] ?? current.values;
await ctx.editor.call('set_property_values', { propertyValues: { [leafId]: { [xKey]: (values[xKey] ?? 0) - door.hinge[0], [yKey]: (values[yKey] ?? 0) - door.hinge[1] } } });
ctx.ids.set(`hinge:${door.id}`, hingeId); ctx.ids.set(`leaf:${door.id}`, leafId); ctx.ids.set(`light:${door.id}`, await findId(ctx, LIGHT_ID[door.id]));
}
async function hitShapes(ctx: Ctx): Promise<void> {
const shapes = ctx.plan.doors.map(door => ({ primitive: 'rectangle', name: `hit_${door.id}`, x: door.hit.x + door.hit.width / 2, y: door.hit.y + door.hit.height / 2, width: door.hit.width, height: door.hit.height, paints: [{ paintType: 'fill', color: '#00000000' }] }));
const result = await ctx.editor.call<{ shapes?: Array<{ id: string; name: string }>; objects?: Array<{ id: string; name: string }> }>('path_editor', { command: 'createParametricShapes', data: { createParametricShapes: { shapes } } });
for (const shape of result.shapes ?? result.objects ?? []) ctx.ids.set(`hit:${shape.name.replace('hit_', '')}`, shape.id);
for (const door of ctx.plan.doors) if (!ctx.ids.has(`hit:${door.id}`)) ctx.ids.set(`hit:${door.id}`, await findId(ctx, `hit_${door.id}`));
}
async function viewModel(ctx: Ctx): Promise<void> {
await ctx.editor.call('viewmodel_editor', { command: 'createDataEnums', data: { createDataEnums: { dataEnums: [{ name: ctx.plan.enum.name, values: [...ctx.plan.enum.values] }] } } });
const created = await ctx.editor.call<{ viewModels: Array<{ id: string; name: string; properties?: Array<{ id: string; name: string }>; viewModelProperties?: Array<{ id: string; name: string }> }> }>('viewmodel_editor', { command: 'createViewModels', data: { createViewModels: { viewModels: [{ name: ctx.plan.viewModel.name, viewModelProperties: ctx.plan.viewModel.properties.map(p => p.type === 'enum' ? { name: p.name, propertyType: 'enum', enumName: ctx.plan.enum.name } : { name: p.name, propertyType: p.type }) }] } } });
const vm = created.viewModels[0]!;
const props: Record<string, string> = {};
for (const p of vm.properties ?? vm.viewModelProperties ?? []) props[p.name] = p.id;
if (Object.keys(props).length === 0) {
const listed = await ctx.editor.call<{ viewModels: Array<{ id: string; name: string; properties: Array<{ id: string; name: string }> }> }>('viewmodel_editor', { command: 'listViewModels', data: { listViewModels: {} } });
for (const p of listed.viewModels.find(v => v.id === vm.id)!.properties) props[p.name] = p.id;
}
ctx.vm = { id: vm.id, props };
await ctx.editor.call('viewmodel_editor', { command: 'bindViewModelToArtboard', data: { bindViewModelToArtboard: { artboardId: ctx.artboardId, viewModelId: vm.id } } });
}
async function animations(ctx: Ctx): Promise<void> {
const existing = await ctx.editor.call<{ animations?: Array<{ id: string; name: string }>; linearAnimations?: Array<{ id: string; name: string }> }>('animation_editor', { command: 'listLinearAnimations', data: { listLinearAnimations: {} } });
const list = existing.animations ?? existing.linearAnimations ?? [];
const [first, ...rest] = ctx.plan.animations;
const pristine = list.find(a => a.name === 'Timeline');
if (pristine) { await ctx.editor.call('animation_editor', { command: 'renameAnimations', data: { renameAnimations: { animations: [{ animationId: pristine.id, name: first!.name }] } } }); ctx.animations[first!.name] = pristine.id; }
const toCreate = pristine ? rest : ctx.plan.animations;
const created = await ctx.editor.call<{ animations?: Array<{ id: string; name: string }>; linearAnimations?: Array<{ id: string; name: string }> }>('animation_editor', { command: 'createLinearAnimations', data: { createLinearAnimations: { linearAnimations: toCreate.map(a => ({ name: a.name, duration: a.durationFrames })) } } });
for (const a of created.animations ?? created.linearAnimations ?? []) ctx.animations[a.name] = a.id;
for (const a of ctx.plan.animations) if (!ctx.animations[a.name]) throw new Error(`animation ${a.name} was not created`);
for (const a of ctx.plan.animations) {
const loopKey = await propertyKey(ctx, ctx.animations[a.name]!, 'loop').catch(() => null);
if (loopKey !== null) await ctx.editor.call('set_property_values', { propertyValues: { [ctx.animations[a.name]!]: { [loopKey]: a.loop === 'oneShot' ? 0 : a.loop === 'loop' ? 1 : 2 } } });
const add = [];
for (const key of a.keys) {
const objectId = ctx.ids.get(`${key.target}:${key.door}`)!;
add.push({ objectId, propertyKey: await propertyKey(ctx, objectId, key.property), frame: key.frame, value: key.value, interpolationType: key.interpolation, ...(key.interpolation === 'cubic' ? { cubicParams: { x1: 0.42, y1: 0, x2: 0.2, y2: 1 } } : {}) });
}
await ctx.editor.call('animation_editor', { command: 'modifyKeyFrames', data: { modifyKeyFrames: { animationId: ctx.animations[a.name], add } } });
}
}
async function stateMachine(ctx: Ctx): Promise<void> {
const machines = await ctx.editor.call<{ stateMachines: Array<{ id: string; name: string; layers?: Array<{ id: string; name: string }> }> }>('animation_editor', { command: 'listStateMachines', data: { listStateMachines: {} } });
const machine = machines.stateMachines[0] ?? (await ctx.editor.call<{ stateMachine: { id: string; name: string } }>('animation_editor', { command: 'createStateMachine', data: { createStateMachine: { name: 'Lobby Machine' } } })).stateMachine;
ctx.stateMachineId = machine.id; ctx.stateMachineName = machine.name;
const created = await ctx.editor.call<{ layers: Array<{ id: string; name: string; states?: Array<{ id: string; name: string }> }> }>('animation_editor', { command: 'createStateMachineLayers', data: { createStateMachineLayers: { stateMachineId: machine.id, layers: ctx.plan.layers.map(layer => ({ name: layer.name, states: layer.states.map((s, i) => ({ name: s.name, x: 120 + i * 220, y: 120, ...(s.animation ? { linearAnimationName: s.animation } : {}) })), otherTransitions: layer.transitions.map(t => ({ from: t.from, to: t.to })) })) } } });
const full = await ctx.editor.call<any>('animation_editor', { command: 'queryStateMachine', data: { queryStateMachine: { stateMachineId: machine.id } } });
const conditions: Array<{ id: string; conditions: any[] }> = [];
for (const layer of ctx.plan.layers) {
const live = (full.layers as any[]).find(l => l.name === layer.name);
for (const t of layer.transitions) {
if (!t.when) continue;
const from = t.from === '{Any State}' ? 'Any State' : t.from;
const liveT = (live.transitions ?? []).find((x: any) => (x.fromStateName ?? x.from) .replace(/[{}]/g, '') === from && (x.toStateName ?? x.to) === t.to) ?? (live.states ?? []).flatMap((s: any) => (s.transitions ?? []).map((x: any) => ({ ...x, fromStateName: s.name }))).find((x: any) => x.fromStateName.replace(/[{}]/g, '') === from && (x.toStateName ?? x.to) === t.to);
if (!liveT) throw new Error(`transition ${t.from} → ${t.to} missing in layer ${layer.name}`);
const propertyId = ctx.vm.props[t.when.property]!;
conditions.push({ id: liveT.id, conditions: [t.when.equals === undefined ? { leftComparator: { viewModelPropertyId: propertyId } } : { leftComparator: { viewModelPropertyId: propertyId }, comparationOperation: 'equal', rightComparator: { valueType: 'constantValueType', value: t.when.equals } }] });
}
}
await ctx.editor.call('animation_editor', { command: 'createConditions', data: { createConditions: { transitions: conditions } } });
await ctx.editor.call('create_listeners', { stateMachineId: machine.id, listeners: ctx.plan.doors.flatMap(door => [
{ name: `hover_${door.id}`, targetId: ctx.ids.get(`hit:${door.id}`), listenerTypes: ['enter'], actions: [{ type: 'viewModelChange', viewModelPropertyId: ctx.vm.props.focus, value: door.id }] },
{ name: `leave_${door.id}`, targetId: ctx.ids.get(`hit:${door.id}`), listenerTypes: ['exit'], actions: [{ type: 'viewModelChange', viewModelPropertyId: ctx.vm.props.focus, value: 'none' }] },
{ name: `click_${door.id}`, targetId: ctx.ids.get(`hit:${door.id}`), listenerTypes: ['click'], actions: [{ type: 'viewModelChange', viewModelPropertyId: ctx.vm.props[`open${TITLE[door.id]}`] }] },
]) });
}
export async function authorLobby(replace = false): Promise<void> {
const refinedDir = resolve(repoRoot, 'scenes/refined/lobby');
const outDir = resolve(repoRoot, 'scenes/rive/lobby'); mkdirSync(outDir, { recursive: true });
const log: LogEntry[] = [];
const editor = new RiveEditor(undefined, entry => { log.push(entry); writeFileSync(join(outDir, 'authoring-log.json'), JSON.stringify(log, null, 1)); });
const server = await editor.initialize();
const session = await editor.call<{ activeFileId: number; activeFileName: string; openTabs: Array<{ url: string; isActive: boolean }> }>('session_info');
if (!session.activeFileId) throw new Error('No file is open in Rive Early Access');
console.log(`editor ${server.name} ${server.version} · file "${session.activeFileName}" (${session.activeFileId})`);
const manifest = JSON.parse(readFileSync(join(refinedDir, 'manifest.json'), 'utf8')) as RefinedManifest;
const plan = planLobby(manifest);
const boards = await editor.call<{ artboards: Array<{ id: string; name: string }> }>('list_artboards');
const existing = boards.artboards.find(b => b.name === plan.artboard);
if (existing && !replace) throw new Error(`Artboard ${plan.artboard} already exists; pass --replace to delete and rebuild it`);
if (existing) await editor.call('delete_objects', { objectIds: [existing.id] });
const created = await editor.call<{ artboards: Array<{ id: string; name: string }> }>('open_file_editor', { command: 'createArtboard', data: { createArtboard: [{ name: plan.artboard, width: plan.width, height: plan.height }] } });
const artboardId = created.artboards[0]!.id;
await editor.call('open_file_editor', { command: 'focusArtboard', data: { focusArtboard: { artboardId, fitToViewport: true } } });
const ctx: Ctx = { editor, plan, artboardId, ids: new Map(), keys: new Map(), vm: { id: '', props: {} }, animations: {}, stateMachineId: '', stateMachineName: '' };
const sceneAsset = await editor.uploadSvg(join(refinedDir, plan.scene.file), 'lobby-scene');
await editor.call('assets_tool', { command: 'addSvgInstance', data: { addSvgInstance: { assetId: sceneAsset, name: plan.scene.nodeName, parentId: artboardId, x: 0, y: 0 } } });
for (const door of plan.doors) await hinge(ctx, door);
await hitShapes(ctx);
await viewModel(ctx);
await animations(ctx);
await stateMachine(ctx);
await editor.capture(plan.artboard, join(outDir, 'capture.png'));
const exported = await editor.exportRiv(outDir);
const finalPath = join(outDir, 'lobby.riv');
if (exported.path !== finalPath) { if (existsSync(finalPath)) renameSync(finalPath, join(outDir, `lobby.previous.riv`)); renameSync(exported.path, finalPath); }
const bytes = readFileSync(finalPath);
writeFileSync(join(outDir, 'rive-manifest.json'), JSON.stringify({
room: 'lobby', file: 'lobby.riv', sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length,
artboard: plan.artboard, stateMachine: ctx.stateMachineName, viewModel: plan.viewModel.name, enum: { [plan.enum.name]: plan.enum.values },
inputs: Object.fromEntries(plan.viewModel.properties.map(p => [p.name, p.type])), animations: plan.animations.map(a => a.name),
doors: plan.doors, editor: { fileId: session.activeFileId, url: session.openTabs.find(t => t.isActive)?.url ?? '' }, authoredAt: new Date().toISOString(),
}, null, 2) + '\n');
console.log(`exported ${finalPath} (${bytes.length} bytes), state machine "${ctx.stateMachineName}", ${log.length} editor calls`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
await authorLobby(process.argv.includes('--replace'));
}
```
- [ ] **Step 6: Run it against the editor (the target file must be active)**
Run: `npm run rive:author -- --room lobby`
Expected: prints the editor/file, then `exported …/scenes/rive/lobby/lobby.riv (N bytes)` with N well above 13; `capture.png` shows the lobby. Open `capture.png`. If any tool result's shape differs from what the code expects (ids under a different key), fix the accessor in `rive-author.ts`, run again with `--replace`, and do not hand-edit anything in the editor.
- [ ] **Step 7: Commit**
```bash
git add tools/rive-plan.ts tools/rive-author.ts tests/unit/rive-plan.test.ts scenes/rive/lobby
git commit -m "Author the lobby artboard through the editor MCP and export lobby.riv"
```
---
### Task 8: Verify the export headlessly
**Files:**
- Create: `tools/verify-riv.ts`
- Test: `tests/unit/verify-riv.test.ts`
**Interfaces:**
- Produces: `verifyRiv(room: RoomId): Promise<{ ok: true; artboard: string; animations: string[]; stateMachines: string[]; viewModelProperties: string[] }>`; throws with a precise message when the file disagrees with `rive-manifest.json`.
- [ ] **Step 1: Write the failing test**
`tests/unit/verify-riv.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { verifyRiv } from '../../tools/verify-riv.ts';
test('the exported lobby.riv matches its rive-manifest', { skip: !existsSync(new URL('../../scenes/rive/lobby/lobby.riv', import.meta.url)) }, async () => {
const result = await verifyRiv('lobby');
assert.equal(result.artboard, 'Lobby');