A continuation of Janady13/thatdeveloperguy-website / docs/superpowers/plans/2026-09-15-lobby-hero.md. The complete source identity, every recorded occurrence, and linked equations are on its first page.
Source transcript · continued
Download complete source text ↓2,200 lines · 121.9 KB
for (const name of ['Ambient', 'DoorOpenIt', 'DoorOpenGovernment', 'DoorOpenCyber', 'FocusIt', 'FocusGovernment', 'FocusCyber']) assert.ok(result.animations.includes(name), `missing animation ${name}`);
assert.equal(result.stateMachines.length, 1);
for (const name of ['focus', 'openIt', 'openGovernment', 'openCyber', 'reducedMotion']) assert.ok(result.viewModelProperties.includes(name), `missing view-model property ${name}`);
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/verify-riv.ts'`
- [ ] **Step 3: Write `tools/verify-riv.ts`**
```ts
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repoRoot, type RoomId } from './kits.ts';
const require = createRequire(import.meta.url);
/** Loads the runtime file with the low-level Canvas WASM in Node (no DOM) and reads back what the editor exported. */
export async function verifyRiv(room: RoomId) {
const dir = resolve(repoRoot, 'scenes/rive', room);
const manifest = JSON.parse(readFileSync(resolve(dir, 'rive-manifest.json'), 'utf8'));
const bytes = readFileSync(resolve(dir, manifest.file));
if (bytes.subarray(0, 4).toString() !== 'RIVE') throw new Error('not a RIVE file');
const RiveCanvas = (await import('@rive-app/canvas-advanced')).default as (options: { locateFile: (file: string) => string }) => Promise<any>;
const rive = await RiveCanvas({ locateFile: file => require.resolve(`@rive-app/canvas-advanced/${file}`) });
const file = await rive.load(new Uint8Array(bytes));
const artboard = file.artboardByName(manifest.artboard);
if (!artboard) throw new Error(`artboard ${manifest.artboard} not in ${manifest.file}`);
const animations: string[] = []; for (let i = 0; i < artboard.animationCount(); i++) animations.push(artboard.animationByIndex(i).name);
const stateMachines: string[] = []; for (let i = 0; i < artboard.stateMachineCount(); i++) stateMachines.push(artboard.stateMachineByIndex(i).name);
const viewModel = file.viewModelByName ? file.viewModelByName(manifest.viewModel) : null;
const viewModelProperties: string[] = viewModel ? (viewModel.getProperties?.() ?? viewModel.properties ?? []).map((p: any) => p.name) : [];
for (const name of manifest.animations) if (!animations.includes(name)) throw new Error(`animation ${name} missing; file has ${animations.join(', ')}`);
if (!stateMachines.includes(manifest.stateMachine)) throw new Error(`state machine ${manifest.stateMachine} missing; file has ${stateMachines.join(', ')}`);
for (const name of Object.keys(manifest.inputs)) if (!viewModelProperties.includes(name)) throw new Error(`view-model property ${name} missing; file has ${viewModelProperties.join(', ')}`);
file.delete?.(); rive.cleanup?.();
return { ok: true as const, artboard: manifest.artboard, animations, stateMachines, viewModelProperties };
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const index = process.argv.indexOf('--room');
const result = await verifyRiv((index > 0 ? process.argv[index + 1] : 'lobby') as RoomId);
console.log(JSON.stringify(result, null, 2));
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test`
Expected: PASS. If `@rive-app/canvas-advanced` refuses to initialize in Node (`window is not defined`), shim before import — `Object.assign(globalThis, { window: globalThis, self: globalThis })` — and re-run. If `viewModelByName` is absent in 2.42.1, read `file.viewModelCount()` / `file.viewModelByIndex(i)` instead and adapt; the assertion on property names stays.
- [ ] **Step 5: Commit**
```bash
git add tools/verify-riv.ts tests/unit/verify-riv.test.ts
git commit -m "Verify exported .riv files headlessly against their manifest"
```
---
### Task 9: Truth projection and web app shell
**Files:**
- Create: `tools/import-truth.ts`, `apps/web/src/truth/projection.json` (generated), `apps/web/src/truth/index.ts`, `apps/web/index.html`, `apps/web/vite.config.ts`, `apps/web/src/main.tsx`, `apps/web/src/App.tsx`, `apps/web/src/components/Header.tsx`, `apps/web/src/components/Footer.tsx`, `apps/web/src/styles/tokens.css`, `apps/web/src/styles/base.css`, `apps/web/src/styles/layout.css`, `apps/web/src/rooms/{it-services,government-solutions,cybersecurity}/Room.tsx`, `apps/web/src/rooms/contact/Contact.tsx`
- Test: `tests/unit/truth.test.ts`
**Interfaces:**
- Produces `projection.json`:
```json
{ "schema": "tdg-site-truth/v1", "sourceCommit": "<git sha of ~/Code/thatdeveloperguy-truth>", "importedAt": "",
"org": { "id": "https://thatdeveloperguy.com/#organization", "name": "ThatDeveloperGuy", "legalName": "THATDEVELOPERGUY LLC", "telephone": "+14176712606", "telephoneDisplay": "", "email": "admin@thatdeveloperguy.com", "foundingDate": "2017", "address": {}, "sameAs": [] },
"person": { "id": "https://thatdeveloperguy.com/#person", "name": "Joseph W. Anady", "jobTitle": "Founder and Principal Engineer", "url": "https://feedthejoe.com/joseph/" },
"host": { "host": "thatdeveloperguy.com", "role": "<properties.hosts[].role verbatim>" },
"ownerToSupply": ["CAGE code", "SAM.gov Active date", "SBA VetCert status", "NAICS approval", "PSC codes", "Named past-performance clients"] }
```
and `export const truth: Projection` from `apps/web/src/truth/index.ts`.
- [ ] **Step 1: Write the failing test**
`tests/unit/truth.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { project } from '../../tools/import-truth.ts';
test('project copies node wording verbatim and lists what only the owner can supply', () => {
const node = { canonicalHost: 'https://thatdeveloperguy.com/', org: { '@id': 'https://thatdeveloperguy.com/#organization', name: 'ThatDeveloperGuy', legalName: 'THATDEVELOPERGUY LLC', telephone: '+14176712606', telephoneDisplay: '417 671 2606', email: 'admin@thatdeveloperguy.com', foundingDate: '2017', address: { addressLocality: 'Cassville', addressRegion: 'MO' }, sameAs: ['https://www.wikidata.org/wiki/Q139709771'] }, person: { '@id': 'https://thatdeveloperguy.com/#person', name: 'Joseph W. Anady', jobTitle: 'Founder and Principal Engineer', url: 'https://feedthejoe.com/joseph/' }, properties: { hosts: [{ host: 'thatdeveloperguy.com', role: 'the main face' }] } };
const p = project(node as any, 'abc123');
assert.equal(p.schema, 'tdg-site-truth/v1'); assert.equal(p.sourceCommit, 'abc123');
assert.equal(p.org.legalName, 'THATDEVELOPERGUY LLC'); assert.equal(p.org.telephoneDisplay, '417 671 2606');
assert.equal(p.person.jobTitle, 'Founder and Principal Engineer');
assert.equal(p.host.role, 'the main face');
assert.ok(p.ownerToSupply.includes('CAGE code'));
assert.equal(JSON.stringify(p).includes('SDVOSB'), false);
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../tools/import-truth.ts'`
- [ ] **Step 3: Write `tools/import-truth.ts`**
```ts
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repoRoot } from './kits.ts';
export const NODE_ROOT = '/Users/josephanady/Code/thatdeveloperguy-truth';
export const OWNER_TO_SUPPLY = ['CAGE code', 'SAM.gov Active date', 'SBA VetCert status', 'NAICS approval', 'PSC codes', 'Named past-performance clients'] as const;
export interface Projection {
schema: 'tdg-site-truth/v1'; sourceCommit: string; importedAt: string;
org: { id: string; name: string; legalName: string; telephone: string; telephoneDisplay: string; email: string; foundingDate: string; address: Record<string, string>; sameAs: string[] };
person: { id: string; name: string; jobTitle: string; url: string };
host: { host: string; role: string };
ownerToSupply: string[];
}
/** Copies fields verbatim; never derives, rewrites or "improves" wording. */
export function project(node: any, sourceCommit: string): Projection {
const host = (node.properties?.hosts ?? []).find((h: any) => h.host === 'thatdeveloperguy.com') ?? { host: 'thatdeveloperguy.com', role: '' };
return {
schema: 'tdg-site-truth/v1', sourceCommit, importedAt: new Date().toISOString(),
org: { id: node.org['@id'], name: node.org.name, legalName: node.org.legalName, telephone: node.org.telephone, telephoneDisplay: node.org.telephoneDisplay ?? '', email: node.org.email, foundingDate: String(node.org.foundingDate ?? ''), address: node.org.address ?? {}, sameAs: node.org.sameAs ?? [] },
person: { id: node.person['@id'], name: node.person.name, jobTitle: node.person.jobTitle, url: node.person.url },
host: { host: host.host, role: host.role },
ownerToSupply: [...OWNER_TO_SUPPLY],
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const node = JSON.parse(readFileSync(resolve(NODE_ROOT, 'organization.json'), 'utf8'));
const commit = execFileSync('git', ['-C', NODE_ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const out = resolve(repoRoot, 'apps/web/src/truth'); mkdirSync(out, { recursive: true });
writeFileSync(resolve(out, 'projection.json'), JSON.stringify(project(node, commit), null, 2) + '\n');
console.log(`truth projected from ${NODE_ROOT}@${commit.slice(0, 8)}`);
}
```
- [ ] **Step 4: Run the test and the import**
Run: `npm test && npm run truth && cat apps/web/src/truth/projection.json`
Expected: PASS; the JSON shows the org/person fields above with the node's wording.
- [ ] **Step 5: Write the app shell**
`apps/web/src/truth/index.ts`:
```ts
import projection from './projection.json';
export type Projection = typeof projection;
export const truth: Projection = projection;
```
`apps/web/index.html`:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ThatDeveloperGuy — Government IT. Security. Real solutions.</title>
<meta name="description" content="ThatDeveloperGuy LLC — IT services, government solutions and cybersecurity from Cassville, Missouri." />
<link rel="preconnect" href="https://fonts.googleapis.com" /><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;600;700&family=Archivo+Black&display=swap" />
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
```
`apps/web/vite.config.ts`:
```ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [react()],
server: { fs: { allow: [fileURLToPath(new URL('../..', import.meta.url))] } },
build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' },
});
```
`apps/web/src/main.tsx`:
```tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App';
import './styles/tokens.css'; import './styles/base.css'; import './styles/layout.css';
createRoot(document.getElementById('root')!).render(<StrictMode><BrowserRouter><App /></BrowserRouter></StrictMode>);
```
`apps/web/src/App.tsx`:
```tsx
import { Route, Routes } from 'react-router';
import { Header } from './components/Header';
import { Footer } from './components/Footer';
import { Lobby } from './rooms/lobby/Lobby';
import { Room as ITServices } from './rooms/it-services/Room';
import { Room as GovernmentSolutions } from './rooms/government-solutions/Room';
import { Room as Cybersecurity } from './rooms/cybersecurity/Room';
import { Contact } from './rooms/contact/Contact';
export function App() {
return (
<>
<Header />
<main id="content">
<Routes>
<Route path="/" element={<Lobby />} />
<Route path="/it-services/" element={<ITServices />} />
<Route path="/government-solutions/" element={<GovernmentSolutions />} />
<Route path="/cybersecurity/" element={<Cybersecurity />} />
<Route path="/contact/" element={<Contact />} />
</Routes>
</main>
<Footer />
</>
);
}
```
`apps/web/src/components/Header.tsx`:
```tsx
import { Link } from 'react-router';
import { truth } from '../truth';
export function Header() {
return (
<header className="site-header">
<Link to="/" className="site-brand" aria-label={`${truth.org.name} — lobby`}>thatdeveloper<span>guy</span>.com</Link>
<nav aria-label="Rooms" className="site-nav">
<Link to="/it-services/">IT Services</Link><Link to="/government-solutions/">Government Solutions</Link><Link to="/cybersecurity/">Cybersecurity</Link><Link to="/contact/" className="site-nav-cta">Request a briefing</Link>
</nav>
</header>
);
}
```
`apps/web/src/components/Footer.tsx`:
```tsx
import { truth } from '../truth';
export function Footer() {
const a = truth.org.address;
return (
<footer className="site-footer">
<p><strong>{truth.org.legalName}</strong> · {a.addressLocality}, {a.addressRegion} · <a href={`tel:${truth.org.telephone}`}>{truth.org.telephoneDisplay || truth.org.telephone}</a> · <a href={`mailto:${truth.org.email}`}>{truth.org.email}</a></p>
<p className="site-footer-note">Founded {truth.org.foundingDate}. Registry identifiers the owner still has to supply are marked on the pages that need them.</p>
</footer>
);
}
```
`apps/web/src/styles/tokens.css`:
```css
:root {
--paper: #f4f1fa; --lavender: #d9d1ea; --purple: #4a2f8a; --purple-deep: #2f1f5e; --ink: #2a1f4a; --status: #33c26b;
--font-body: 'Public Sans', system-ui, sans-serif; --font-display: 'Archivo Black', 'Public Sans', sans-serif;
--header-h: 64px; --room-w: 1648; --room-h: 928;
}
```
`apps/web/src/styles/base.css`:
```css
*, *::before, *::after { box-sizing: border-box; }
html { color-scheme: light; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--font-body); line-height: 1.5; }
a { color: var(--purple); }
h1, h2, h3 { font-family: var(--font-display); font-weight: 400; letter-spacing: -0.01em; line-height: 1.1; margin: 0 0 .5em; }
:focus-visible { outline: 3px dashed var(--purple); outline-offset: 3px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
```
`apps/web/src/styles/layout.css`:
```css
.site-header { position: sticky; top: 0; z-index: 10; height: var(--header-h); display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: rgba(244, 241, 250, .92); backdrop-filter: blur(8px); border-bottom: 1px solid var(--lavender); }
.site-brand { font-family: var(--font-display); font-size: 1.15rem; color: var(--ink); text-decoration: none; } .site-brand span { color: var(--purple); }
.site-nav { display: flex; gap: 20px; } .site-nav a { text-decoration: none; font-weight: 600; } .site-nav-cta { padding: 8px 14px; border-radius: 999px; background: var(--purple); color: #fff !important; }
.room-overview { max-width: 1100px; margin: 0 auto; padding: 56px 24px; display: grid; gap: 32px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.owner-to-supply { border: 2px dashed var(--purple); border-radius: 8px; padding: 12px 16px; background: #fff; }
.site-footer { padding: 32px 24px; border-top: 1px solid var(--lavender); font-size: .95rem; } .site-footer-note { color: #5c4d85; }
@media (max-width: 767px) { .site-nav { display: none; } }
```
Placeholder rooms — `apps/web/src/rooms/it-services/Room.tsx` (and the same file body with the title changed for `government-solutions` → "Government Solutions", `cybersecurity` → "Cybersecurity"):
```tsx
import { Link } from 'react-router';
export function Room() {
return (
<section className="room-overview" aria-labelledby="room-title">
<div><h1 id="room-title">IT Services</h1><p>This room is built in the next plan. <Link to="/">Return to the lobby</Link>.</p></div>
</section>
);
}
```
`apps/web/src/rooms/contact/Contact.tsx`:
```tsx
import { useSearchParams } from 'react-router';
import { truth } from '../../truth';
export function Contact() {
const [params] = useSearchParams();
const project = params.get('project') ?? 'general';
return (
<section className="room-overview" aria-labelledby="contact-title">
<div>
<h1 id="contact-title">Request a briefing</h1>
<p>Intent: <code>{project}</code>. The mail service is wired in the next plan; until then call <a href={`tel:${truth.org.telephone}`}>{truth.org.telephoneDisplay || truth.org.telephone}</a> or write to <a href={`mailto:${truth.org.email}`}>{truth.org.email}</a>.</p>
</div>
</section>
);
}
```
- [ ] **Step 6: Typecheck and start the dev server once**
Run: `npm run typecheck && (npm run dev & sleep 4; curl -s http://127.0.0.1:4410/ | head -5; kill %1)`
Expected: no type errors (the lobby import will fail until Task 10 — create `apps/web/src/rooms/lobby/Lobby.tsx` exporting `export function Lobby() { return <p>lobby</p>; }` for now); the HTML shell is served.
- [ ] **Step 7: Commit**
```bash
git add tools/import-truth.ts tests/unit/truth.test.ts apps/web
git commit -m "Truth projection from the node; React app shell, routes and tokens"
```
---
### Task 10: The stage — poster, Rive, hit shapes, caption
**Files:**
- Create: `scripts/sync-rive-wasm.mjs`, `scripts/sync-scenes.mjs`, `apps/web/src/animation/rive/RiveScene.tsx`, `apps/web/src/animation/stage/SceneStage.tsx`, `apps/web/src/styles/stage.css`, `apps/web/src/rooms/lobby/hotspots.ts`, `apps/web/src/rooms/lobby/content.ts`, `apps/web/src/rooms/lobby/Lobby.tsx`
- Test: `tests/unit/hotspots.test.ts`
**Interfaces:**
- `RiveScene` props: `{ src: string; artboard: string; stateMachine: string; viewModel: string; focus: string; reducedMotion: boolean; fire: { name: string; nonce: number } | null; onReady(): void; onError(error: unknown): void }` — writes `focus` to the enum property whenever it changes, sets `reducedMotion`, fires the trigger named `fire.name` each time `fire.nonce` changes.
- `SceneStage` props: `{ poster: string; rive: { src: string; artboard: string; stateMachine: string; viewModel: string } | null; hotspots: Hotspot[]; caption: (id: string | null) => string; onActivate(h: Hotspot): Promise<void> | void; label: string }` where `interface Hotspot { id: string; label: string; href: string; hit: { x: number; y: number; width: number; height: number }; focusValue: string; trigger: string }`.
- `hotspots.ts`: `export const LOBBY_HOTSPOTS: Hotspot[]` derived from `scenes/rive/lobby/rive-manifest.json` `doors[]` at import time.
- `sync-scenes.mjs` copies `scenes/refined/lobby/scene.svg` → `apps/web/public/scenes/lobby/scene.svg` and `scenes/rive/lobby/lobby.riv` + `rive-manifest.json` → `apps/web/public/scenes/lobby/`; `sync-rive-wasm.mjs` copies the version-matched `@rive-app/webgl2` `rive.wasm` to `apps/web/public/animation/runtime/rive.wasm` (the TSC script, path adjusted).
- [ ] **Step 1: Write the failing test**
`tests/unit/hotspots.test.ts`:
```ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hotspotsFromManifest } from '../../apps/web/src/rooms/lobby/hotspots.ts';
test('lobby hotspots come from the rive manifest doors, with focus values and triggers', () => {
const list = hotspotsFromManifest({ doors: [{ id: 'it', leafId: 'door_it_leaf', hinge: [564, 529], hit: { x: 542, y: 229, width: 176, height: 302 }, href: '/it-services/', label: 'IT SERVICES' }] } as any);
assert.deepEqual(list, [{ id: 'it', label: 'IT SERVICES', href: '/it-services/', hit: { x: 542, y: 229, width: 176, height: 302 }, focusValue: 'it', trigger: 'openIt' }]);
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test`
Expected: FAIL — `Cannot find module '../../apps/web/src/rooms/lobby/hotspots.ts'`
- [ ] **Step 3: Write the sync scripts and run them**
`scripts/sync-rive-wasm.mjs`:
```js
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { existsSync, readFileSync, copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
process.chdir(fileURLToPath(new URL('..', import.meta.url)));
const require = createRequire(import.meta.url);
const reactEntry = require.resolve('@rive-app/react-webgl2');
const runtimeEntry = createRequire(reactEntry).resolve('@rive-app/webgl2');
let dir = dirname(runtimeEntry);
while (!existsSync(join(dir, 'package.json'))) { const parent = dirname(dir); if (parent === dir) throw new Error('runtime package root not found'); dir = parent; }
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
if (pkg.name !== '@rive-app/webgl2') throw new Error('unexpected runtime package');
const out = 'apps/web/public/animation/runtime'; mkdirSync(out, { recursive: true });
copyFileSync(join(dir, 'rive.wasm'), join(out, 'rive.wasm'));
writeFileSync(join(out, 'version.json'), JSON.stringify({ name: pkg.name, version: pkg.version, wasmSHA256: createHash('sha256').update(readFileSync(join(dir, 'rive.wasm'))).digest('hex') }, null, 2));
console.log(`self-hosted Rive WebGL2 runtime ${pkg.version}`);
```
`scripts/sync-scenes.mjs`:
```js
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
process.chdir(fileURLToPath(new URL('..', import.meta.url)));
for (const room of ['lobby']) {
const out = `apps/web/public/scenes/${room}`; mkdirSync(out, { recursive: true });
copyFileSync(`scenes/refined/${room}/scene.svg`, `${out}/scene.svg`);
for (const file of [`${room}.riv`, 'rive-manifest.json']) if (existsSync(`scenes/rive/${room}/${file}`)) copyFileSync(`scenes/rive/${room}/${file}`, `${out}/${file}`);
console.log(`synced ${room}`);
}
```
Run: `npm run sync`
Expected: both scripts print; `apps/web/public/scenes/lobby/{scene.svg,lobby.riv,rive-manifest.json}` and `apps/web/public/animation/runtime/rive.wasm` exist.
- [ ] **Step 4: Write `hotspots.ts`, `content.ts`, `RiveScene.tsx`, `SceneStage.tsx`, `stage.css`, `Lobby.tsx`**
`apps/web/src/rooms/lobby/hotspots.ts`:
```ts
import manifest from '../../../../../scenes/rive/lobby/rive-manifest.json';
export interface Hotspot { id: string; label: string; href: string; hit: { x: number; y: number; width: number; height: number }; focusValue: string; trigger: string }
interface DoorRecord { id: string; leafId: string; hinge: [number, number]; hit: Hotspot['hit']; href: string; label: string }
const TITLE: Record<string, string> = { it: 'It', government: 'Government', cyber: 'Cyber' };
export function hotspotsFromManifest(m: { doors: DoorRecord[] }): Hotspot[] {
return m.doors.map(door => ({ id: door.id, label: door.label, href: door.href, hit: door.hit, focusValue: door.id, trigger: `open${TITLE[door.id] ?? door.id}` }));
}
export const LOBBY_HOTSPOTS = hotspotsFromManifest(manifest as any);
export const LOBBY_RIVE = { src: `/scenes/lobby/${(manifest as any).file}`, artboard: (manifest as any).artboard as string, stateMachine: (manifest as any).stateMachine as string, viewModel: (manifest as any).viewModel as string };
```
`apps/web/src/rooms/lobby/content.ts`:
```ts
/** Lobby copy. Wording that describes the organization comes from the node projection; these are room captions only. */
export const LOBBY_CAPTIONS: Record<string, string> = {
it: 'IT Services — support, endpoints, infrastructure and deployment.',
government: 'Government Solutions — planning, requirements, contracting and delivery.',
cyber: 'Cyber Security — monitoring, review, response and infrastructure protection.',
};
export const LOBBY_REST_CAPTION = 'Three doors. Pick the room you need.';
```
`apps/web/src/animation/rive/RiveScene.tsx`:
```tsx
import { useEffect, useRef } from 'react';
import { useRive, Layout, Fit, Alignment, RuntimeLoader, useViewModel, useViewModelInstance, useViewModelInstanceEnum, useViewModelInstanceBoolean, useViewModelInstanceTrigger } from '@rive-app/react-webgl2';
if (typeof window !== 'undefined') { RuntimeLoader.setWasmUrl('/animation/runtime/rive.wasm'); RuntimeLoader.setWasmFallbackUrl(null); }
export interface RiveSceneProps { src: string; artboard: string; stateMachine: string; viewModel: string; focus: string; reducedMotion: boolean; fire: { name: string; nonce: number } | null; onReady: () => void; onError: (error: unknown) => void }
/** Plays a room's state machine and mirrors page state into its view model. Pointer events never reach this canvas; the HTML overlay owns them. */
export function RiveScene({ src, artboard, stateMachine, viewModel, focus, reducedMotion, fire, onReady, onError }: RiveSceneProps) {
const callbacks = useRef({ onReady, onError }); callbacks.current = { onReady, onError };
const { rive, RiveComponent } = useRive({ src, artboard, stateMachines: stateMachine, autoplay: true, autoBind: true, enableRiveAssetCDN: false, shouldDisableRiveListeners: true, layout: new Layout({ fit: Fit.Cover, alignment: Alignment.Center }), onLoad: () => callbacks.current.onReady(), onLoadError: e => callbacks.current.onError(e) }, { shouldResizeCanvasToContainer: true, useDevicePixelRatio: true });
const model = useViewModel(rive, { name: viewModel });
const instance = useViewModelInstance(model, { rive });
const focusProperty = useViewModelInstanceEnum('focus', instance);
const reduced = useViewModelInstanceBoolean('reducedMotion', instance);
const trigger = useViewModelInstanceTrigger(fire?.name ?? 'openIt', instance);
useEffect(() => { focusProperty.setValue(focus); }, [focus, focusProperty.setValue]);
useEffect(() => { reduced.setValue(reducedMotion); }, [reducedMotion, reduced.setValue]);
useEffect(() => { if (fire) trigger.trigger(); }, [fire?.nonce]);
return <div className="stage-rive" aria-hidden="true"><RiveComponent /></div>;
}
```
Before relying on the hook names, run `grep -o "export declare function useViewModel[A-Za-z]*" node_modules/@rive-app/react-webgl2/dist/types/index.d.ts` (or the package README) and match exactly; if `useViewModelInstanceTrigger` needs `{ onTrigger }`, keep the call shape it documents.
`apps/web/src/animation/stage/SceneStage.tsx`:
```tsx
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router';
import { RiveScene } from '../rive/RiveScene';
import type { Hotspot } from '../../rooms/lobby/hotspots';
import '../../styles/stage.css';
export interface SceneStageProps { poster: string; rive: { src: string; artboard: string; stateMachine: string; viewModel: string } | null; hotspots: Hotspot[]; caption: (id: string | null) => string; onActivate: (h: Hotspot) => Promise<void> | void; label: string; focus: string | null; onFocus: (id: string | null) => void; fire: { name: string; nonce: number } | null }
/** 1648×928 room coordinates mapped to the stage; poster underneath, Rive on top, real links on top of that. */
export function SceneStage({ poster, rive, hotspots, caption, onActivate, label, focus, onFocus, fire }: SceneStageProps) {
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => { const m = matchMedia('(prefers-reduced-motion: reduce)'); const u = () => setReducedMotion(m.matches); u(); m.addEventListener('change', u); return () => m.removeEventListener('change', u); }, []);
const activate = useCallback((event: React.MouseEvent, h: Hotspot) => { event.preventDefault(); void onActivate(h); }, [onActivate]);
return (
<section className={`stage${ready ? ' stage-live' : ''}`} aria-label={label} data-focus={focus ?? ''}>
<div className="stage-frame">
<img className="stage-poster" src={poster} alt="" width={1648} height={928} decoding="async" />
{rive && !failed && !reducedMotion && <RiveScene {...rive} focus={focus ?? 'none'} reducedMotion={reducedMotion} fire={fire} onReady={() => setReady(true)} onError={() => setFailed(true)} />}
<svg className="stage-hits" viewBox="0 0 1648 928" role="navigation" aria-label={`${label} destinations`}>
{hotspots.map(h => (
<Link key={h.id} to={h.href} aria-label={h.label} className="stage-hit" data-hit={h.id} onMouseEnter={() => onFocus(h.id)} onMouseLeave={() => onFocus(null)} onFocus={() => onFocus(h.id)} onBlur={() => onFocus(null)} onClick={e => activate(e, h)}>
<rect x={h.hit.x} y={h.hit.y} width={h.hit.width} height={h.hit.height} rx={6} />
</Link>
))}
</svg>
</div>
<p className="stage-caption" aria-live="polite">{caption(focus)}</p>
</section>
);
}
```
`react-router`'s `Link` renders an `<a>`; inside an SVG it must be an SVG anchor. If React warns about `<a>` inside `<svg>`, replace `Link` with a plain `<a href={h.href} …>` and call `navigate(h.href)` from `onActivate`.
`apps/web/src/styles/stage.css`:
```css
.stage { position: relative; min-height: calc(100svh - var(--header-h)); display: grid; grid-template-rows: 1fr auto; background: var(--lavender); }
.stage-frame { position: relative; aspect-ratio: 1648 / 928; max-height: calc(100svh - var(--header-h) - 48px); margin: 0 auto; width: min(100%, calc((100svh - var(--header-h) - 48px) * 1648 / 928)); overflow: hidden; }
.stage-poster, .stage-rive, .stage-hits { position: absolute; inset: 0; width: 100%; height: 100%; }
.stage-poster { display: block; object-fit: cover; }
.stage-rive { opacity: 0; transition: opacity .4s ease; pointer-events: none; } .stage-live .stage-rive { opacity: 1; }
.stage-rive canvas { width: 100% !important; height: 100% !important; display: block; }
.stage-hits { z-index: 2; } .stage-hit { cursor: pointer; } .stage-hit rect { fill: transparent; stroke: transparent; stroke-width: 3; stroke-dasharray: 8 6; }
.stage-hit:focus-visible { outline: none; } .stage-hit:focus-visible rect { stroke: var(--purple); }
.stage-caption { margin: 0; padding: 12px 24px; text-align: center; font-weight: 600; color: var(--purple-deep); min-height: 48px; }
@media (max-width: 767px) { .stage-frame { max-height: none; width: 100%; } .stage-rive { display: none; } }
```
`apps/web/src/rooms/lobby/Lobby.tsx`:
```tsx
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router';
import { SceneStage } from '../../animation/stage/SceneStage';
import { LOBBY_HOTSPOTS, LOBBY_RIVE, type Hotspot } from './hotspots';
import { LOBBY_CAPTIONS, LOBBY_REST_CAPTION } from './content';
import { truth } from '../../truth';
const DOOR_OPEN_MS = 26 / 60 * 1000 + 120;
export function Lobby() {
const navigate = useNavigate();
const [focus, setFocus] = useState<string | null>(null);
const [fire, setFire] = useState<{ name: string; nonce: number } | null>(null);
const [leaving, setLeaving] = useState(false);
const activate = useCallback(async (h: Hotspot) => {
if (leaving) return; setLeaving(true);
setFocus(h.id); setFire({ name: h.trigger, nonce: Date.now() });
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
await new Promise(r => setTimeout(r, reduced ? 0 : DOOR_OPEN_MS));
navigate(h.href);
}, [leaving, navigate]);
return (
<>
<h1 className="sr-only">{truth.org.name} — {truth.host.role}</h1>
<SceneStage poster="/scenes/lobby/scene.svg" rive={LOBBY_RIVE} hotspots={LOBBY_HOTSPOTS} label="Lobby" focus={focus} onFocus={id => { if (!leaving) setFocus(id); }} fire={fire} onActivate={activate} caption={id => (id ? LOBBY_CAPTIONS[id] ?? '' : LOBBY_REST_CAPTION)} />
<section className="room-overview" aria-label="About this organization">
<div><h2>{truth.org.name}</h2><p>{truth.org.legalName}, founded {truth.org.foundingDate}, {truth.org.address.addressLocality}, {truth.org.address.addressRegion}.</p><p>{truth.person.name}, {truth.person.jobTitle}.</p></div>
<div className="owner-to-supply"><h2>Registry facts to be supplied by the owner</h2><ul>{truth.ownerToSupply.map(item => <li key={item}>{item}</li>)}</ul></div>
</section>
</>
);
}
```
- [ ] **Step 5: Run the unit tests, typecheck, and look at it**
Run: `npm test && npm run typecheck && npm run dev`
Then open `http://127.0.0.1:4410/` in Chrome. Expected: header, the refined lobby as poster, then the Rive canvas fading in over it (same geometry — no jump), the caption changing on hover over each door, dashed focus ring on Tab, and a click on a door opening it and landing on the placeholder room. Check the console for Rive load errors; if `useViewModelInstanceEnum` rejects the value, confirm the enum value keys in `rive-manifest.json` (`none`, `it`, `government`, `cyber`).
- [ ] **Step 6: Commit**
```bash
git add scripts apps/web/src tests/unit/hotspots.test.ts
git commit -m "Scene stage: poster, Rive view-model bridge, hit-shape links, caption; lobby route"
```
---
### Task 11: Browser tests and fps measurement
**Files:**
- Create: `playwright.config.ts`, `tests/browser/lobby.spec.ts`, `tools/shots.ts`
- [ ] **Step 1: Write the Playwright config and the failing spec**
`playwright.config.ts`:
```ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/browser', timeout: 30_000, retries: 0, reporter: 'list',
use: { baseURL: 'http://127.0.0.1:4410', trace: 'retain-on-failure' },
webServer: { command: 'npm run dev', url: 'http://127.0.0.1:4410', reuseExistingServer: true, timeout: 60_000 },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, { name: 'mobile', use: { ...devices['iPhone 14'] } }],
});
```
`tests/browser/lobby.spec.ts`:
```ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('the lobby shows the poster and three real door links', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.stage-poster')).toBeVisible();
const doors = page.locator('.stage-hit');
await expect(doors).toHaveCount(3);
await expect(doors.nth(0)).toHaveAttribute('href', '/it-services/');
await expect(doors.nth(1)).toHaveAttribute('href', '/government-solutions/');
await expect(doors.nth(2)).toHaveAttribute('href', '/cybersecurity/');
});
test('hovering a door names it in the caption; keyboard reaches every door', async ({ page, isMobile }) => {
test.skip(isMobile, 'hover is desktop-only');
await page.goto('/');
await page.locator('.stage-hit[data-hit="government"]').hover();
await expect(page.locator('.stage-caption')).toContainText('Government Solutions');
await page.keyboard.press('Tab'); // brand
for (let i = 0; i < 6; i++) { await page.keyboard.press('Tab'); if (await page.locator('.stage-hit:focus').count()) break; }
await expect(page.locator('.stage-hit:focus')).toHaveCount(1);
});
test('clicking the IT door opens it and navigates', async ({ page }) => {
await page.goto('/');
await page.locator('.stage-hit[data-hit="it"]').click();
await expect(page).toHaveURL(/\/it-services\/$/, { timeout: 5000 });
await expect(page.getByRole('heading', { level: 1 })).toContainText('IT Services');
});
test('the lobby has no axe violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations, JSON.stringify(results.violations, null, 1)).toEqual([]);
});
```
- [ ] **Step 2: Install browsers and run the spec**
Run: `npx playwright install chromium webkit && npm run test:browser`
Expected: all four tests pass on chromium and webkit, the first/third/fourth on mobile. Fix real failures in the app, not the tests; an axe "region" complaint on the stage means the `<section aria-label>` is missing.
- [ ] **Step 3: Write `tools/shots.ts`**
```ts
/** Frames and fps of `/` in headless Chrome with the real GPU: node --import tsx tools/shots.ts [--cpu 4] */
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const arg = (k: string, d: string) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1]! : d; };
const BASE = arg('--base', 'http://127.0.0.1:4410'), CPU = Number(arg('--cpu', '1')), OUT = arg('--out', 'shots/lobby'), W = 1440, H = 900;
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
if (!existsSync(CHROME)) throw new Error('Google Chrome not found');
mkdirSync(OUT, { recursive: true });
const profile = mkdtempSync(join(tmpdir(), 'tdg-shots-'));
const chrome = spawn(CHROME, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', `--window-size=${W},${H}`, '--hide-scrollbars', '--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist'], { stdio: 'ignore' });
const portFile = join(profile, 'DevToolsActivePort');
for (let i = 0; i < 100 && !existsSync(portFile); i++) await new Promise(r => setTimeout(r, 100));
const port = readFileSync(portFile, 'utf8').split('\n')[0]!.trim();
const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json() as { webSocketDebuggerUrl: string };
const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise<void>((res, rej) => { ws.addEventListener('open', () => res()); ws.addEventListener('error', rej); });
let id = 0; const waits = new Map<number, (v: any) => void>();
ws.addEventListener('message', e => { const m = JSON.parse(String(e.data)); if (m.id && waits.has(m.id)) { waits.get(m.id)!(m); waits.delete(m.id); } });
const send = (method: string, params: Record<string, unknown> = {}) => { const n = ++id; ws.send(JSON.stringify({ id: n, method, params })); return new Promise<any>(r => waits.set(n, r)); };
const ev = async (expr: string) => (await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value;
const shot = async (name: string) => { const r = await send('Page.captureScreenshot', { format: 'jpeg', quality: 85 }); writeFileSync(join(OUT, `${name}.jpg`), Buffer.from(r.result.data, 'base64')); };
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false });
await send('Emulation.setCPUThrottlingRate', { rate: CPU });
await send('Page.navigate', { url: `${BASE}/` });
await wait(1500); await shot('00-arrive'); await wait(1500); await shot('01-live');
const renderer = await ev(`(() => { const c = document.createElement('canvas'); const g = c.getContext('webgl2'); const d = g && g.getExtension('WEBGL_debug_renderer_info'); return d ? g.getParameter(d.UNMASKED_RENDERER_WEBGL) : 'no webgl2'; })()`);
const live = await ev(`document.querySelector('.stage-live') !== null`);
await ev(`document.querySelector('.stage-hit[data-hit="government"]').dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))`); await wait(400); await shot('02-focus-government');
const fps = await ev(`(async () => { const t0 = performance.now(); let f = 0; await new Promise(r => { const tick = () => { f++; if (performance.now() - t0 < 2000) requestAnimationFrame(tick); else r(); }; requestAnimationFrame(tick); }); return Math.round(f / ((performance.now() - t0) / 1000)); })()`);
const result = { renderer, riveLive: live, cpu: CPU, fpsIdle: fps, at: new Date().toISOString() };
writeFileSync(join(OUT, `fps-${CPU}x.json`), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
ws.close(); chrome.kill(); await new Promise(r => chrome.once('exit', r)); rmSync(profile, { recursive: true, force: true });
if (!live) { console.error('Rive did not go live in headless Chrome'); process.exit(1); }
if (fps < (CPU >= 4 ? 30 : 55)) { console.error(`fps ${fps} below the bar`); process.exit(1); }
```
- [ ] **Step 4: Measure at 1× and 4×**
Run: `npm run dev & sleep 4; npm run shots; npm run shots -- --cpu 4; kill %1`
Expected: `riveLive: true`, `fpsIdle ≥ 55` at 1× and `≥ 30` at 4×; three JPEGs in `shots/lobby/`. If fps is below the bar, the refined scene is too heavy for the runtime: raise `DEFAULT_REFINE.tolerance` to 1.0 and `minArea` to 6, re-run `npm run refine -- --room lobby`, `npm run rive:author -- --room lobby --replace`, `npm run rive:verify`, `npm run sync`, and measure again. Record the final numbers in the commit message.
- [ ] **Step 5: Commit**
```bash
git add playwright.config.ts tests/browser/lobby.spec.ts tools/shots.ts
git commit -m "Browser tests (nav, keyboard, axe) and headless fps measurement for the lobby"
```
---
### Task 12: Hand-off for the owner's verdict
- [ ] **Step 1: Run the full verification in order**
Run: `npm test && npm run typecheck && npm run rive:verify && npm run test:browser`
Expected: all green.
- [ ] **Step 2: Start the dev server for Joseph and report**
Run: `npm run dev`
Report in chat (no report file): the URL `http://127.0.0.1:4410/`, before→after vertex counts and diff ratio from `scenes/refined/lobby/manifest.json`, `lobby.riv` size, state-machine/input names from `rive-manifest.json`, fps at 1× and 4×, the editor URL of the file, and what Plan 2 covers (three rooms, mascot rig, walk-through, contact API, prerender, release). Do not start Plan 2 until Joseph has judged the lobby in Chrome.
---
## Self-review
- Spec coverage: repo layout (T1, T9), vector pipeline steps 1–6 (T3–T5), Rive authoring steps 1–7 for the lobby (T7), headless verification (T8), truth projection + owner placeholders (T9), stage with poster/Rive/hit shapes/caption, door click → trigger → navigate, reduced motion, mobile poster (T10), browser gates + fps (T11). Mascot rig, rooms 2–4, contact API, prerender, release: Plan 2 by design.
- Type consistency: `RefinedManifest` (T5) is consumed by `planLobby` (T7); `Hotspot` (T10) is produced by `hotspotsFromManifest` and consumed by `SceneStage`; `rive-manifest.json` fields written in T7 are read in T8 and T10 with the same names (`file`, `artboard`, `stateMachine`, `viewModel`, `inputs`, `animations`, `doors`).
- Known editor-shape uncertainty is isolated in `rive-author.ts` accessors (ids under `id`/`nodeId`/`group.id`, `properties`/`viewModelProperties`, `animations`/`linearAnimations`) and in the `@rive-app/react-webgl2` view-model hook names; both tasks say to confirm against the live tool result / package types before trusting the code.