12 TDD tasks proving the vertical slice end-to-end on the AFTERIMAGE pilot: scaffold, store, clock, timeline/drift, dice core, Year-Zero rule-pack, scenario validator+data, formatting, <director-rail>, <dice-tray>, <gm-shell>, and the offline PWA (manifest + service worker). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
48 KiB
The Director — Slice 1 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: Build the offline-first shell + always-on Director Rail + scenario data model + one animated dice engine (Year-Zero rule-pack), proven end-to-end against the AFTERIMAGE pilot scenario.
Architecture: Vanilla ES-module Web Components over a thin core of pure-logic modules (persistence, session clock, timeline/drift, dice). Components are dumb renderers that emit events; all testable logic lives in src/core and src/dice with injected now/rng for determinism. Vite builds a static PWA; a hand-written service worker precaches the shell for offline launch.
Tech Stack: JavaScript (ES modules, no TypeScript), Web Components (custom elements, light DOM), Vite (dev/build), Vitest + jsdom (tests), hand-written service worker + web manifest for PWA/offline.
Global Constraints
- Offline-first: app must fully function with no server and no network. Network features are out of Slice 1.
- No runtime framework: custom elements only; the only deps are dev tools (vite, vitest, jsdom).
- Determinism in logic: no
Date.now()/Math.random()inside core logic — callers injectnow(ms) andrng(() => [0,1)). - Persistence namespace: all localStorage keys are
gmd.<namespace>.<key>; scenario state uses namespace =meta.id. - Timeline units:
targetMinis minutes after the GM taps Start. Drift isstamp − targetMin; positive = behind. - Test env: Vitest
environment: 'jsdom'. Custom elements use light DOM (no shadow root) for styling and test simplicity. - Node: ≥ 18.
Task 1: Project scaffold + test harness
Files:
- Create:
package.json - Create:
vite.config.js - Create:
index.html - Create:
src/main.js - Create:
tests/smoke.test.js
Interfaces:
-
Consumes: nothing.
-
Produces:
npm test,npm run dev,npm run buildscripts; jsdom test environment. -
Step 1: Write
package.json
{
"name": "gm-director",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"jsdom": "^24.0.0",
"vite": "^5.2.0",
"vitest": "^1.6.0"
}
}
- Step 2: Write
vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/**/*.test.js'],
},
});
- Step 3: Write
index.html(mount point)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>The Director</title>
</head>
<body>
<gm-shell></gm-shell>
<script type="module" src="/src/main.js"></script>
</body>
</html>
- Step 4: Write
src/main.js(placeholder boot)
// Boot entry. Component registration is added in later tasks.
export const APP = 'the-director';
- Step 5: Write the smoke test
// tests/smoke.test.js
import { describe, it, expect } from 'vitest';
import { APP } from '../src/main.js';
describe('scaffold', () => {
it('boots and exposes the app id', () => {
expect(APP).toBe('the-director');
});
it('has a working DOM environment', () => {
document.body.innerHTML = '<div id="x">hi</div>';
expect(document.getElementById('x').textContent).toBe('hi');
});
});
- Step 6: Install and run
Run: npm install && npm test
Expected: 2 tests PASS.
- Step 7: Commit
git add package.json vite.config.js index.html src/main.js tests/smoke.test.js
git commit -m "chore: scaffold Vite + Vitest PWA project"
Task 2: Persistence store
Files:
- Create:
src/core/store.js - Test:
tests/core/store.test.js
Interfaces:
-
Produces:
createStore(namespace, storage = globalThis.localStorage)→{ get(key, fallback), set(key, value), remove(key), exportAll(), importAll(obj) }. Keys are namespacedgmd.<namespace>.<key>; values are JSON-encoded. -
Step 1: Write the failing test
// tests/core/store.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { createStore } from '../../src/core/store.js';
function fakeStorage() {
const map = new Map();
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
key: (i) => [...map.keys()][i] ?? null,
get length() { return map.size; },
};
}
describe('createStore', () => {
let s, storage;
beforeEach(() => { storage = fakeStorage(); s = createStore('afterimage', storage); });
it('round-trips JSON values under a namespaced key', () => {
s.set('clock', { startedAt: 5 });
expect(storage.getItem('gmd.afterimage.clock')).toBe('{"startedAt":5}');
expect(s.get('clock')).toEqual({ startedAt: 5 });
});
it('returns fallback for missing/corrupt keys', () => {
expect(s.get('missing', 42)).toBe(42);
storage.setItem('gmd.afterimage.bad', '{not json');
expect(s.get('bad', 'fb')).toBe('fb');
});
it('exports only its namespace and imports back', () => {
s.set('a', 1);
storage.setItem('gmd.other.z', '9');
const dump = s.exportAll();
expect(Object.keys(dump)).toEqual(['gmd.afterimage.a']);
const s2 = createStore('afterimage', fakeStorage());
s2.importAll(dump);
expect(s2.get('a')).toBe(1);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/core/store.test.js
Expected: FAIL — cannot find module src/core/store.js.
- Step 3: Write minimal implementation
// src/core/store.js
const PREFIX = 'gmd';
export function createStore(namespace, storage = globalThis.localStorage) {
const full = (key) => `${PREFIX}.${namespace}.${key}`;
const nsPrefix = `${PREFIX}.${namespace}.`;
return {
get(key, fallback = null) {
const raw = storage.getItem(full(key));
if (raw == null) return fallback;
try { return JSON.parse(raw); } catch { return fallback; }
},
set(key, value) { storage.setItem(full(key), JSON.stringify(value)); },
remove(key) { storage.removeItem(full(key)); },
exportAll() {
const out = {};
for (let i = 0; i < storage.length; i++) {
const k = storage.key(i);
if (k && k.startsWith(nsPrefix)) out[k] = storage.getItem(k);
}
return out;
},
importAll(obj) {
for (const [k, v] of Object.entries(obj)) {
if (k.startsWith(nsPrefix)) storage.setItem(k, v);
}
},
};
}
- Step 4: Run test to verify it passes
Run: npx vitest run tests/core/store.test.js
Expected: PASS (3 tests).
- Step 5: Commit
git add src/core/store.js tests/core/store.test.js
git commit -m "feat: namespaced localStorage store with export/import"
Task 3: Session clock (pure logic)
Files:
- Create:
src/core/clock.js - Test:
tests/core/clock.test.js
Interfaces:
-
Produces:
initialClock()→ state{ startedAt, pausedAt, pausedAccumMs };start(state, now),pause(state, now),resume(state, now)→ new state;elapsedMs(state, now)→ number;isRunning(state),isPaused(state)→ boolean.nowis milliseconds injected by the caller. -
Step 1: Write the failing test
// tests/core/clock.test.js
import { describe, it, expect } from 'vitest';
import { initialClock, start, pause, resume, elapsedMs, isRunning, isPaused } from '../../src/core/clock.js';
describe('session clock', () => {
it('reads zero before start', () => {
const c = initialClock();
expect(elapsedMs(c, 1000)).toBe(0);
expect(isRunning(c)).toBe(false);
});
it('measures elapsed from start', () => {
const c = start(initialClock(), 1000);
expect(isRunning(c)).toBe(true);
expect(elapsedMs(c, 61000)).toBe(60000); // 1 minute
});
it('freezes while paused and excludes paused time after resume', () => {
let c = start(initialClock(), 0);
c = pause(c, 60000); // paused at 1:00
expect(isPaused(c)).toBe(true);
expect(elapsedMs(c, 120000)).toBe(60000); // frozen at 1:00 while paused
c = resume(c, 180000); // resumed after 2 min paused
expect(elapsedMs(c, 240000)).toBe(120000); // 4 min wall − 2 min paused = 2 min
});
it('ignores double start', () => {
const c = start(start(initialClock(), 100), 5000);
expect(elapsedMs(c, 1100)).toBe(1000);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/core/clock.test.js
Expected: FAIL — module not found.
- Step 3: Write minimal implementation
// src/core/clock.js
export function initialClock() {
return { startedAt: null, pausedAt: null, pausedAccumMs: 0 };
}
export function start(state, now) {
if (state.startedAt != null) return state;
return { startedAt: now, pausedAt: null, pausedAccumMs: 0 };
}
export function pause(state, now) {
if (state.startedAt == null || state.pausedAt != null) return state;
return { ...state, pausedAt: now };
}
export function resume(state, now) {
if (state.pausedAt == null) return state;
return { ...state, pausedAt: null, pausedAccumMs: state.pausedAccumMs + (now - state.pausedAt) };
}
export function elapsedMs(state, now) {
if (state.startedAt == null) return 0;
const end = state.pausedAt != null ? state.pausedAt : now;
return Math.max(0, end - state.startedAt - state.pausedAccumMs);
}
export function isRunning(state) { return state.startedAt != null && state.pausedAt == null; }
export function isPaused(state) { return state.pausedAt != null; }
- Step 4: Run test to verify it passes
Run: npx vitest run tests/core/clock.test.js
Expected: PASS (4 tests).
- Step 5: Commit
git add src/core/clock.js tests/core/clock.test.js
git commit -m "feat: pure session clock with pause/resume"
Task 4: Timeline analysis (drift + next hard trigger)
Files:
- Create:
src/core/timeline.js - Test:
tests/core/timeline.test.js
Interfaces:
-
Produces:
sortedTimeline(timeline)→ new array sorted bytargetMin.nextUnreachedBeat(timeline, stamps)→ first sorted beat whoseidis not a key instamps, ornull.analyze(timeline, elapsedMin, stamps)→{ currentBeat, nextBeat, nextHardTrigger, minutesToNextHard, driftMin, cutHint }.stampsmapsbeatId → elapsedMinAtStamp.currentBeat= last stamped beat (ornull).driftMin=stamps[currentBeat.id] − currentBeat.targetMin(ornull).minutesToNextHard=nextHardTrigger.targetMin − elapsedMin(ornull).cutHint=nextBeat?.cutHint ?? null.
-
Step 1: Write the failing test
// tests/core/timeline.test.js
import { describe, it, expect } from 'vitest';
import { sortedTimeline, nextUnreachedBeat, analyze } from '../../src/core/timeline.js';
const T = [
{ id: 'a', act: 1, label: 'Open', targetMin: 0, hardTrigger: true },
{ id: 'b', act: 1, label: 'Parlor', targetMin: 35, cutHint: 'skip the tea' },
{ id: 'c', act: 2, label: 'Door-cam', targetMin: 120, hardTrigger: true },
];
describe('timeline analysis', () => {
it('sorts by targetMin without mutating input', () => {
const shuffled = [T[2], T[0], T[1]];
expect(sortedTimeline(shuffled).map((b) => b.id)).toEqual(['a', 'b', 'c']);
expect(shuffled[0].id).toBe('c');
});
it('finds the next unreached beat', () => {
expect(nextUnreachedBeat(T, {}).id).toBe('a');
expect(nextUnreachedBeat(T, { a: 0 }).id).toBe('b');
});
it('before any stamp: no current, next hard is the first hard beat', () => {
const m = analyze(T, 5, {});
expect(m.currentBeat).toBe(null);
expect(m.nextBeat.id).toBe('a');
expect(m.driftMin).toBe(null);
expect(m.nextHardTrigger.id).toBe('a');
});
it('after stamping "a" late: drift is behind, next hard is "c"', () => {
const m = analyze(T, 40, { a: 6 }); // reached Open at 6 min (target 0) => 6 behind
expect(m.currentBeat.id).toBe('a');
expect(m.driftMin).toBe(6);
expect(m.nextBeat.id).toBe('b');
expect(m.cutHint).toBe('skip the tea');
expect(m.nextHardTrigger.id).toBe('c');
expect(m.minutesToNextHard).toBe(80); // 120 - 40
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/core/timeline.test.js
Expected: FAIL — module not found.
- Step 3: Write minimal implementation
// src/core/timeline.js
export function sortedTimeline(timeline) {
return [...timeline].sort((a, b) => a.targetMin - b.targetMin);
}
export function nextUnreachedBeat(timeline, stamps) {
const t = sortedTimeline(timeline);
return t.find((b) => !(b.id in stamps)) || null;
}
export function analyze(timeline, elapsedMin, stamps) {
const t = sortedTimeline(timeline);
const reached = t.filter((b) => b.id in stamps);
const currentBeat = reached.length ? reached[reached.length - 1] : null;
const currentIndex = currentBeat ? t.findIndex((b) => b.id === currentBeat.id) : -1;
const nextBeat = t[currentIndex + 1] || null;
const nextHardTrigger = t.slice(currentIndex + 1).find((b) => b.hardTrigger) || null;
const minutesToNextHard = nextHardTrigger ? nextHardTrigger.targetMin - elapsedMin : null;
const driftMin = currentBeat ? stamps[currentBeat.id] - currentBeat.targetMin : null;
return { currentBeat, nextBeat, nextHardTrigger, minutesToNextHard, driftMin, cutHint: nextBeat?.cutHint ?? null };
}
- Step 4: Run test to verify it passes
Run: npx vitest run tests/core/timeline.test.js
Expected: PASS (4 tests).
- Step 5: Commit
git add src/core/timeline.js tests/core/timeline.test.js
git commit -m "feat: timeline drift + next-hard-trigger analysis"
Task 5: Dice core (seedable roller)
Files:
- Create:
src/dice/roller.js - Test:
tests/dice/roller.test.js
Interfaces:
-
Produces:
expandDice(diceSpecs)—[{ sides, count? }]→ flat[{ sides }].rollDie(sides, rng)→ integer1..sides.rollPool(diceSpecs, rng)→{ results: [{ sides, value }], total }.rngis() => [0,1).
-
Step 1: Write the failing test
// tests/dice/roller.test.js
import { describe, it, expect } from 'vitest';
import { expandDice, rollDie, rollPool } from '../../src/dice/roller.js';
// deterministic rng that replays a fixed sequence
function seq(values) { let i = 0; return () => values[i++ % values.length]; }
describe('dice core', () => {
it('expands count into individual dice', () => {
expect(expandDice([{ sides: 8, count: 3 }])).toEqual([{ sides: 8 }, { sides: 8 }, { sides: 8 }]);
expect(expandDice([{ sides: 6 }])).toEqual([{ sides: 6 }]);
});
it('maps rng to inclusive 1..sides', () => {
expect(rollDie(8, () => 0)).toBe(1);
expect(rollDie(8, () => 0.999)).toBe(8);
expect(rollDie(6, () => 0.5)).toBe(4);
});
it('rolls a pool and totals it', () => {
const rng = seq([0.9, 0.0, 0.7]); // d8 -> 8, 1, 6
const { results, total } = rollPool([{ sides: 8, count: 3 }], rng);
expect(results.map((r) => r.value)).toEqual([8, 1, 6]);
expect(total).toBe(15);
expect(results[0]).toEqual({ sides: 8, value: 8 });
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/dice/roller.test.js
Expected: FAIL — module not found.
- Step 3: Write minimal implementation
// src/dice/roller.js
export function expandDice(diceSpecs) {
const out = [];
for (const d of diceSpecs) {
const count = d.count ?? 1;
for (let i = 0; i < count; i++) out.push({ sides: d.sides });
}
return out;
}
export function rollDie(sides, rng) {
return Math.floor(rng() * sides) + 1;
}
export function rollPool(diceSpecs, rng) {
const results = expandDice(diceSpecs).map((d) => ({ sides: d.sides, value: rollDie(d.sides, rng) }));
const total = results.reduce((s, r) => s + r.value, 0);
return { results, total };
}
- Step 4: Run test to verify it passes
Run: npx vitest run tests/dice/roller.test.js
Expected: PASS (3 tests).
- Step 5: Commit
git add src/dice/roller.js tests/dice/roller.test.js
git commit -m "feat: seedable dice core with pools"
Task 6: Year-Zero rule-pack + registry
Files:
- Create:
src/dice/rulepacks/year-zero.js - Create:
src/dice/rulepacks/index.js - Test:
tests/dice/year-zero.test.js
Interfaces:
-
Produces:
yearZero—{ id: 'year-zero', label: 'Year-Zero', interpret(results) }whereinterpret([{ sides, value }])→{ successes, ones, canPush, isCritical }. Rule:value >= 10→ +2 successes;value 6..9→ +1;value === 1→ +1 one;isCritical=successes >= 2.getRulePack(id)→ pack ornull;registerRulePack(pack)→ void.
-
Step 1: Write the failing test
// tests/dice/year-zero.test.js
import { describe, it, expect } from 'vitest';
import { yearZero } from '../../src/dice/rulepacks/year-zero.js';
import { getRulePack, registerRulePack } from '../../src/dice/rulepacks/index.js';
describe('year-zero rule-pack', () => {
it('counts 6-9 as one success and 10+ as two', () => {
const v = yearZero.interpret([{ sides: 8, value: 8 }, { sides: 8, value: 4 }, { sides: 12, value: 11 }]);
expect(v.successes).toBe(3); // 8 -> 1, 4 -> 0, 11 -> 2
expect(v.isCritical).toBe(true);
});
it('reports ones for stress on a push', () => {
const v = yearZero.interpret([{ sides: 6, value: 1 }, { sides: 6, value: 1 }, { sides: 6, value: 6 }]);
expect(v.ones).toBe(2);
expect(v.successes).toBe(1);
expect(v.canPush).toBe(true);
});
it('is resolvable from the registry by id', () => {
expect(getRulePack('year-zero')).toBe(yearZero);
expect(getRulePack('nope')).toBe(null);
const custom = { id: 'x', label: 'X', interpret: () => ({}) };
registerRulePack(custom);
expect(getRulePack('x')).toBe(custom);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/dice/year-zero.test.js
Expected: FAIL — module not found.
- Step 3: Write the rule-pack
// src/dice/rulepacks/year-zero.js
export const yearZero = {
id: 'year-zero',
label: 'Year-Zero',
interpret(results) {
let successes = 0, ones = 0;
for (const r of results) {
if (r.value >= 10) successes += 2;
else if (r.value >= 6) successes += 1;
if (r.value === 1) ones += 1;
}
return { successes, ones, canPush: true, isCritical: successes >= 2 };
},
};
- Step 4: Write the registry
// src/dice/rulepacks/index.js
import { yearZero } from './year-zero.js';
const packs = new Map([[yearZero.id, yearZero]]);
export function getRulePack(id) { return packs.get(id) || null; }
export function registerRulePack(pack) { packs.set(pack.id, pack); }
- Step 5: Run test to verify it passes
Run: npx vitest run tests/dice/year-zero.test.js
Expected: PASS (3 tests).
- Step 6: Commit
git add src/dice/rulepacks/ tests/dice/year-zero.test.js
git commit -m "feat: Year-Zero rule-pack + rule-pack registry"
Task 7: Scenario validator + AFTERIMAGE pilot data
Files:
- Create:
src/core/scenario.js - Create:
src/scenarios/afterimage.js - Test:
tests/core/scenario.test.js
Interfaces:
-
Consumes: rule-pack ids from Task 6 (
meta.systemmust resolve). -
Produces:
validateScenario(s)→ array of error strings (empty = valid).afterimagedefault export — the pilot scenario object conforming to the data model (meta,timeline, emptyclues/cast/props).
-
Step 1: Write the failing test
// tests/core/scenario.test.js
import { describe, it, expect } from 'vitest';
import { validateScenario } from '../../src/core/scenario.js';
import afterimage from '../../src/scenarios/afterimage.js';
import { getRulePack } from '../../src/dice/rulepacks/index.js';
describe('scenario validation', () => {
it('flags missing required fields', () => {
expect(validateScenario(null)).toContain('scenario must be an object');
const errs = validateScenario({ meta: {}, timeline: [] });
expect(errs).toContain('meta.id required');
expect(errs).toContain('meta.system required');
expect(errs).toContain('timeline must be a non-empty array');
});
it('flags a beat missing targetMin', () => {
const errs = validateScenario({ meta: { id: 'x', system: 'year-zero' }, timeline: [{ id: 'a' }] });
expect(errs).toContain('timeline[0].targetMin must be a number');
});
it('accepts the AFTERIMAGE pilot and its system resolves', () => {
expect(validateScenario(afterimage)).toEqual([]);
expect(afterimage.meta.system).toBe('year-zero');
expect(getRulePack(afterimage.meta.system)).not.toBe(null);
expect(afterimage.timeline.length).toBeGreaterThan(3);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/core/scenario.test.js
Expected: FAIL — modules not found.
- Step 3: Write the validator
// src/core/scenario.js
export function validateScenario(s) {
if (!s || typeof s !== 'object') return ['scenario must be an object'];
const errors = [];
if (!s.meta?.id) errors.push('meta.id required');
if (!s.meta?.system) errors.push('meta.system required');
if (!Array.isArray(s.timeline) || s.timeline.length === 0) {
errors.push('timeline must be a non-empty array');
} else {
s.timeline.forEach((b, i) => {
if (!b.id) errors.push(`timeline[${i}].id required`);
if (typeof b.targetMin !== 'number') errors.push(`timeline[${i}].targetMin must be a number`);
});
}
return errors;
}
- Step 4: Write the pilot scenario
// src/scenarios/afterimage.js
export default {
meta: { id: 'afterimage', title: 'AFTERIMAGE', system: 'year-zero', players: 4, playMinutes: 210, slot: 'Fri · Slot 2' },
timeline: [
{ id: 'a1-open', act: 1, label: 'The pier — the case lands', targetMin: 0, hardTrigger: true },
{ id: 'a1-parlor', act: 1, label: 'Parlor 88 interview', targetMin: 35, cutHint: 'Summarise the ledger; skip the tea ritual' },
{ id: 'a2-market', act: 2, label: 'The Market', targetMin: 90 },
{ id: 'a2-doorcam', act: 2, label: 'Door-cam reveal', targetMin: 120, hardTrigger: true, notes: 'The pivot — do not let this slip past 2:15' },
{ id: 'a3-pier', act: 3, label: 'Pier confrontation', targetMin: 175, hardTrigger: true },
{ id: 'epilogue', act: 4, label: 'Baseline / ambiguous end', targetMin: 200 },
],
clues: [],
cast: [],
props: [],
};
- Step 5: Run test to verify it passes
Run: npx vitest run tests/core/scenario.test.js
Expected: PASS (3 tests).
- Step 6: Commit
git add src/core/scenario.js src/scenarios/afterimage.js tests/core/scenario.test.js
git commit -m "feat: scenario validator + AFTERIMAGE pilot data"
Task 8: Formatting helpers
Files:
- Create:
src/core/format.js - Test:
tests/core/format.test.js
Interfaces:
-
Produces:
fmtElapsed(ms)→"H:MM"(or"0:MM"under an hour);fmtDrift(driftMin)→''when null,'on time'at 0,'+N behind'/'N ahead'otherwise (N = rounded absolute minutes). -
Step 1: Write the failing test
// tests/core/format.test.js
import { describe, it, expect } from 'vitest';
import { fmtElapsed, fmtDrift } from '../../src/core/format.js';
describe('formatting', () => {
it('formats elapsed time', () => {
expect(fmtElapsed(0)).toBe('0:00');
expect(fmtElapsed(65 * 1000)).toBe('0:01');
expect(fmtElapsed(112 * 60 * 1000)).toBe('1:52');
});
it('formats drift with direction', () => {
expect(fmtDrift(null)).toBe('');
expect(fmtDrift(0)).toBe('on time');
expect(fmtDrift(6)).toBe('+6 behind');
expect(fmtDrift(-3)).toBe('3 ahead');
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/core/format.test.js
Expected: FAIL — module not found.
- Step 3: Write minimal implementation
// src/core/format.js
export function fmtElapsed(ms) {
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
return `${h}:${String(m).padStart(2, '0')}`;
}
export function fmtDrift(driftMin) {
if (driftMin == null) return '';
const r = Math.round(driftMin);
if (r === 0) return 'on time';
return r > 0 ? `+${r} behind` : `${Math.abs(r)} ahead`;
}
- Step 4: Run test to verify it passes
Run: npx vitest run tests/core/format.test.js
Expected: PASS (2 tests).
- Step 5: Commit
git add src/core/format.js tests/core/format.test.js
git commit -m "feat: elapsed + drift formatting helpers"
Task 9: <director-rail> component
Files:
- Create:
src/components/director-rail.js - Test:
tests/components/director-rail.test.js
Interfaces:
-
Consumes:
analyze(Task 4),fmtElapsed/fmtDrift(Task 8). -
Produces: custom element
<director-rail>withupdate(patch)merging{ scenario, elapsedMs, stamps }and re-rendering. Emits bubblingCustomEvent('reached')when the "Reached it" button is clicked, andCustomEvent('open-tool', { detail: { tool: 'dice' } })when the dice chip is clicked. Renders elements taggeddata-role="clock|here|next|reached|open-dice". -
Step 1: Write the failing test
// tests/components/director-rail.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/director-rail.js';
import afterimage from '../../src/scenarios/afterimage.js';
describe('<director-rail>', () => {
let el;
beforeEach(() => {
document.body.innerHTML = '';
el = document.createElement('director-rail');
document.body.appendChild(el);
});
it('shows the first beat and no drift before any stamp', () => {
el.update({ scenario: afterimage, elapsedMs: 0, stamps: {} });
expect(el.querySelector('[data-role=here]').textContent).toContain('The pier');
expect(el.querySelector('.drift')).toBe(null);
expect(el.querySelector('[data-role=clock]').textContent).toBe('0:00');
});
it('shows drift and the next hard trigger after a late stamp', () => {
el.update({ scenario: afterimage, elapsedMs: 40 * 60000, stamps: { 'a1-open': 6 } });
expect(el.querySelector('.drift').textContent).toBe('+6 behind');
expect(el.querySelector('[data-role=next]').textContent).toContain('Door-cam');
});
it('emits "reached" when the button is clicked', () => {
el.update({ scenario: afterimage, elapsedMs: 0, stamps: {} });
let fired = 0;
el.addEventListener('reached', () => fired++);
el.querySelector('[data-role=reached]').click();
expect(fired).toBe(1);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/components/director-rail.test.js
Expected: FAIL — module not found.
- Step 3: Write the component
// src/components/director-rail.js
import { analyze } from '../core/timeline.js';
import { fmtElapsed, fmtDrift } from '../core/format.js';
export class DirectorRail extends HTMLElement {
constructor() {
super();
this._state = { scenario: null, elapsedMs: 0, stamps: {} };
}
connectedCallback() { this.render(); }
update(patch) { Object.assign(this._state, patch); this.render(); }
render() {
const { scenario, elapsedMs, stamps } = this._state;
if (!scenario) { this.innerHTML = `<div class="rail rail--empty">No scenario loaded</div>`; return; }
const m = analyze(scenario.timeline, elapsedMs / 60000, stamps);
const here = m.currentBeat ? m.currentBeat.label : (scenario.timeline[0]?.label ?? '—');
const drift = fmtDrift(m.driftMin);
const driftClass = m.driftMin > 0 ? 'behind' : 'ahead';
const nh = m.nextHardTrigger;
const nhText = nh ? `${nh.label} · ~${Math.max(0, Math.round(m.minutesToNextHard))}m` : '—';
this.innerHTML = `
<div class="rail">
<div class="cell"><span class="k">Session</span><span class="v" data-role="clock">${fmtElapsed(elapsedMs)}</span></div>
<div class="cell here"><span class="k">You are here</span>
<span class="v"><span data-role="here">${here}</span>${drift ? ` <span class="drift ${driftClass}">${drift}</span>` : ''}</span></div>
<div class="cell trig"><span class="k">Next hard trigger</span><span class="v" data-role="next">${nhText}</span></div>
<button class="reached" data-role="reached">✓ Reached it</button>
<button class="tray-btn" data-role="open-dice" aria-label="Dice">🎲</button>
</div>`;
this.querySelector('[data-role=reached]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('reached', { bubbles: true }));
});
this.querySelector('[data-role=open-dice]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true }));
});
}
}
customElements.define('director-rail', DirectorRail);
- Step 4: Run test to verify it passes
Run: npx vitest run tests/components/director-rail.test.js
Expected: PASS (3 tests).
- Step 5: Commit
git add src/components/director-rail.js tests/components/director-rail.test.js
git commit -m "feat: <director-rail> pacing component"
Task 10: <dice-tray> component
Files:
- Create:
src/components/dice-tray.js - Test:
tests/components/dice-tray.test.js
Interfaces:
-
Consumes:
rollPool(Task 5),getRulePack(Task 6). -
Produces: custom element
<dice-tray>with settable propssystemId(string),sides(number, default 8),count(number, default 3),rng(defaultMath.random). Methodroll()rolls the pool, interprets via the system's rule-pack, storesthis.last = { results, total, verdict }, re-renders, and emitsCustomEvent('rolled', { detail: this.last }). Picker buttons taggeddata-sides="N"setsides; buttondata-role="roll"callsroll(). Verdict text is in[data-role="verdict"]. -
Step 1: Write the failing test
// tests/components/dice-tray.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/dice-tray.js';
function seq(values) { let i = 0; return () => values[i++ % values.length]; }
describe('<dice-tray>', () => {
let el;
beforeEach(() => {
document.body.innerHTML = '';
el = document.createElement('dice-tray');
el.systemId = 'year-zero';
document.body.appendChild(el);
});
it('renders the polyhedral picker', () => {
expect(el.querySelector('[data-sides="20"]')).not.toBe(null);
expect(el.querySelector('[data-sides="4"]')).not.toBe(null);
});
it('rolls, interprets via the rule-pack, and reports successes', () => {
el.sides = 8; el.count = 3;
el.rng = seq([0.9, 0.0, 0.7]); // d8 -> 8, 1, 6 => 2 successes, 1 one
let detail = null;
el.addEventListener('rolled', (e) => { detail = e.detail; });
el.roll();
expect(el.last.verdict.successes).toBe(2);
expect(el.last.verdict.ones).toBe(1);
expect(detail.verdict.successes).toBe(2);
expect(el.querySelector('[data-role=verdict]').textContent).toContain('2 success');
});
it('changes die type when a picker button is clicked', () => {
el.querySelector('[data-sides="20"]').click();
expect(el.sides).toBe(20);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/components/dice-tray.test.js
Expected: FAIL — module not found.
- Step 3: Write the component
// src/components/dice-tray.js
import { rollPool } from '../dice/roller.js';
import { getRulePack } from '../dice/rulepacks/index.js';
const DICE = [4, 6, 8, 10, 12, 20, 100];
export class DiceTray extends HTMLElement {
constructor() {
super();
this._sides = 8;
this._count = 3;
this._systemId = 'year-zero';
this.rng = Math.random;
this.last = null;
}
get sides() { return this._sides; }
set sides(v) { this._sides = Number(v); this.render(); }
get count() { return this._count; }
set count(v) { this._count = Number(v); }
set systemId(v) { this._systemId = v; }
get systemId() { return this._systemId; }
connectedCallback() { this.render(); }
roll() {
const { results, total } = rollPool([{ sides: this._sides, count: this._count }], this.rng);
const pack = getRulePack(this._systemId);
const verdict = pack ? pack.interpret(results) : null;
this.last = { results, total, verdict };
this.render();
this.dispatchEvent(new CustomEvent('rolled', { detail: this.last, bubbles: true }));
}
render() {
const picker = DICE.map((n) =>
`<button class="die ${n === this._sides ? 'sel' : ''}" data-sides="${n}">d${n}</button>`).join('');
const felt = this.last
? this.last.results.map((r) => `<span class="rolled" data-value="${r.value}">${r.value}</span>`).join('')
: '<span class="hint">Tap Roll</span>';
const verdict = this.last?.verdict
? `${this.last.verdict.successes} success${this.last.verdict.successes === 1 ? '' : 'es'}` +
(this.last.verdict.ones ? ` · ${this.last.verdict.ones} stress` : '')
: '';
this.innerHTML = `
<div class="dice-tray">
<div class="picker">${picker}</div>
<div class="felt">${felt}</div>
<div class="verdict" data-role="verdict">${verdict}</div>
<button class="roll" data-role="roll">Roll</button>
</div>`;
this.querySelectorAll('[data-sides]').forEach((b) =>
b.addEventListener('click', () => { this.sides = Number(b.dataset.sides); }));
this.querySelector('[data-role=roll]').addEventListener('click', () => this.roll());
}
}
customElements.define('dice-tray', DiceTray);
- Step 4: Run test to verify it passes
Run: npx vitest run tests/components/dice-tray.test.js
Expected: PASS (3 tests).
- Step 5: Commit
git add src/components/dice-tray.js tests/components/dice-tray.test.js
git commit -m "feat: <dice-tray> roller wired to rule-packs"
Task 11: <gm-shell> root + wiring + styles
Files:
- Create:
src/components/gm-shell.js - Create:
src/styles.css - Modify:
src/main.js - Modify:
index.html - Test:
tests/components/gm-shell.test.js
Interfaces:
-
Consumes:
createStore(Task 2), clock fns (Task 3),nextUnreachedBeat(Task 4),<director-rail>(Task 9),<dice-tray>(Task 10),afterimage(Task 7). -
Produces: custom element
<gm-shell>that ownsstore,clockState,stamps, andscenario. Settablenowprovider (default() => Date.now()) for tests. MethodloadScenario(scenario);startSession(); onreachedevent it stampsnextUnreachedBeatwith the current elapsed minutes, persistsstamps+clockStateto the store, and refreshes the rail. Onopen-toolit toggles the dice tray's visibility. -
Step 1: Write the failing test
// tests/components/gm-shell.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/gm-shell.js';
import afterimage from '../../src/scenarios/afterimage.js';
describe('<gm-shell>', () => {
let el, t;
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '';
t = 0;
el = document.createElement('gm-shell');
el.now = () => t;
document.body.appendChild(el);
el.loadScenario(afterimage);
});
it('mounts a director-rail for the loaded scenario', () => {
expect(el.querySelector('director-rail')).not.toBe(null);
expect(el.querySelector('director-rail [data-role=here]').textContent).toContain('The pier');
});
it('stamps the next beat on "reached" and persists it', () => {
el.startSession(); // start at t=0
t = 6 * 60000; // 6 minutes later
el.querySelector('[data-role=reached]').dispatchEvent(new CustomEvent('reached', { bubbles: true }));
expect(el.stamps['a1-open']).toBeCloseTo(6, 5);
const persisted = JSON.parse(localStorage.getItem('gmd.afterimage.stamps'));
expect(persisted['a1-open']).toBeCloseTo(6, 5);
// rail now shows the reached beat as current
expect(el.querySelector('director-rail [data-role=here]').textContent).toContain('The pier');
});
it('reveals the dice tray on open-tool', () => {
expect(el.querySelector('dice-tray').hidden).toBe(true);
el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true }));
expect(el.querySelector('dice-tray').hidden).toBe(false);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/components/gm-shell.test.js
Expected: FAIL — module not found.
- Step 3: Write the shell component
// src/components/gm-shell.js
import { createStore } from '../core/store.js';
import { initialClock, start, elapsedMs } from '../core/clock.js';
import { nextUnreachedBeat } from '../core/timeline.js';
import './director-rail.js';
import './dice-tray.js';
export class GmShell extends HTMLElement {
constructor() {
super();
this.now = () => Date.now();
this.scenario = null;
this.store = null;
this.clockState = initialClock();
this.stamps = {};
this._tick = null;
}
connectedCallback() {
this.innerHTML = `
<director-rail></director-rail>
<main class="stage"><dice-tray hidden></dice-tray></main>`;
this.addEventListener('reached', () => this.onReached());
this.addEventListener('open-tool', (e) => this.onOpenTool(e));
}
loadScenario(scenario) {
this.scenario = scenario;
this.store = createStore(scenario.meta.id);
this.clockState = this.store.get('clock', initialClock());
this.stamps = this.store.get('stamps', {});
this.querySelector('dice-tray').systemId = scenario.meta.system;
this.refresh();
}
startSession() {
this.clockState = start(this.clockState, this.now());
this.store.set('clock', this.clockState);
if (!this._tick) this._tick = setInterval(() => this.refresh(), 1000);
this.refresh();
}
elapsedMs() { return elapsedMs(this.clockState, this.now()); }
onReached() {
const beat = nextUnreachedBeat(this.scenario.timeline, this.stamps);
if (!beat) return;
this.stamps = { ...this.stamps, [beat.id]: this.elapsedMs() / 60000 };
this.store.set('stamps', this.stamps);
this.refresh();
}
onOpenTool(e) {
if (e.detail?.tool === 'dice') {
const tray = this.querySelector('dice-tray');
tray.hidden = !tray.hidden;
}
}
refresh() {
this.querySelector('director-rail').update({
scenario: this.scenario,
elapsedMs: this.elapsedMs(),
stamps: this.stamps,
});
}
}
customElements.define('gm-shell', GmShell);
- Step 4: Run test to verify it passes
Run: npx vitest run tests/components/gm-shell.test.js
Expected: PASS (3 tests).
- Step 5: Write
src/styles.css(rail + dice visual language from the mockups)
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b0e13; color: #e7eef6; }
gm-shell { display: block; }
.rail { display: flex; align-items: stretch; height: 64px; background: linear-gradient(180deg,#12171f,#0d1117);
border-bottom: 1px solid #263041; }
.rail .cell { padding: 7px 14px; display: flex; flex-direction: column; justify-content: center; border-right: 1px solid #1e2733; }
.rail .here { flex: 1; }
.rail .k { font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: #6b7a8d; }
.rail .v { font-size: 15px; font-weight: 600; }
.rail .trig .v { color: #ffd479; }
.drift { margin-left: 6px; padding: 1px 7px; border-radius: 20px; font-size: 11px; font-weight: 700; }
.drift.behind { background: #4a1f1f; color: #ff9c9c; }
.drift.ahead { background: #1f3a2a; color: #9cffc4; }
.reached { border: none; padding: 0 18px; background: #1c8a5a; color: #eafff4; font-weight: 700; font-size: 14px; }
.tray-btn { border: none; width: 60px; background: #182130; color: #cfe0f0; font-size: 20px; }
.stage { position: relative; min-height: calc(100vh - 64px); }
.dice-tray { position: absolute; top: 0; left: 0; right: 0; background: #0f141b; border-bottom: 1px solid #2b3644;
padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.dice-tray[hidden] { display: none; }
.picker { display: flex; gap: 8px; flex-wrap: wrap; }
.die { width: 54px; height: 44px; border-radius: 9px; background: #161d27; border: 1px solid #2a3542; color: #c6d5e6; font-weight: 600; }
.die.sel { background: #17251c; border-color: #3aa06a; color: #c9f7dc; }
.felt { min-height: 90px; border-radius: 10px; background: radial-gradient(circle at 50% 40%,#16241c,#0e1712);
border: 1px solid #24352a; display: flex; align-items: center; justify-content: center; gap: 18px; }
.rolled { width: 52px; height: 52px; border-radius: 10px; background: #e9e1d2; color: #2c2618;
display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 800; }
.rolled:nth-child(2) { background: #b7c7d6; } .rolled:nth-child(3) { background: #e7c99a; }
.verdict { font-weight: 700; color: #ffd479; min-height: 20px; }
.roll { border: none; padding: 12px; border-radius: 9px; background: #1c8a5a; color: #eafff4; font-weight: 700; }
- Step 6: Update
src/main.jsto boot the shell
// src/main.js
import './components/gm-shell.js';
import afterimage from './scenarios/afterimage.js';
export const APP = 'the-director';
if (typeof document !== 'undefined' && document.querySelector('gm-shell')) {
customElements.whenDefined('gm-shell').then(() => {
const shell = document.querySelector('gm-shell');
shell.loadScenario(afterimage);
});
}
- Step 7: Link the stylesheet in
index.html
Add inside <head>:
<link rel="stylesheet" href="/src/styles.css" />
- Step 8: Run the full test suite + manual visual check
Run: npm test
Expected: all tests PASS.
Then run: npm run dev, open the served URL, and confirm manually:
-
the rail shows Session
0:00, "You are here: The pier — the case lands", next hard trigger "The pier … ~0m"; -
tapping 🎲 reveals the dice tray; picking a die and tapping Roll shows dice + a success verdict.
-
Step 9: Commit
git add src/components/gm-shell.js src/styles.css src/main.js index.html tests/components/gm-shell.test.js
git commit -m "feat: <gm-shell> wiring, styles, and AFTERIMAGE boot"
Task 12: PWA — offline install (manifest + service worker)
Files:
- Create:
public/manifest.webmanifest - Create:
public/sw.js - Modify:
index.html - Modify:
src/main.js - Test:
tests/pwa.test.js
Interfaces:
-
Consumes: the built app shell.
-
Produces: a linked web manifest + a registered service worker that precaches the shell for offline launch.
src/main.jsregisters/sw.jswhennavigator.serviceWorkerexists. -
Step 1: Write the failing test
// tests/pwa.test.js
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
describe('PWA wiring', () => {
it('links the manifest in index.html', () => {
const html = readFileSync('index.html', 'utf8');
expect(html).toContain('rel="manifest"');
expect(html).toContain('manifest.webmanifest');
});
it('manifest declares an installable app', () => {
const m = JSON.parse(readFileSync('public/manifest.webmanifest', 'utf8'));
expect(m.name).toBeTruthy();
expect(m.display).toBe('standalone');
expect(Array.isArray(m.icons)).toBe(true);
});
it('main.js registers the service worker', () => {
const js = readFileSync('src/main.js', 'utf8');
expect(js).toContain('serviceWorker');
expect(js).toContain("register('/sw.js')");
});
it('service worker precaches the shell', () => {
const sw = readFileSync('public/sw.js', 'utf8');
expect(sw).toContain('addEventListener');
expect(sw).toContain('caches');
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run tests/pwa.test.js
Expected: FAIL — files missing / strings absent.
- Step 3: Write the manifest
{
"name": "The Director",
"short_name": "Director",
"description": "Offline convention GM shell",
"start_url": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#0b0e13",
"theme_color": "#0b0e13",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
- Step 4: Write the service worker
// public/sw.js
const CACHE = 'director-v1';
const SHELL = ['/', '/index.html', '/src/main.js', '/src/styles.css'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
e.respondWith(
caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(e.request, copy));
return res;
}).catch(() => caches.match('/index.html')))
);
});
- Step 5: Register the SW in
src/main.js
Append to src/main.js:
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
- Step 6: Link the manifest in
index.html
Add inside <head>:
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0b0e13" />
- Step 7: Run test to verify it passes
Run: npx vitest run tests/pwa.test.js
Expected: PASS (4 tests).
- Step 8: Manual offline verification
Run: npm run build && npm run preview. In the browser DevTools → Application:
- confirm the manifest is detected and the app is installable;
- confirm the service worker activates;
- tick "Offline" in the Network tab and reload — the app still loads and the rail + dice work.
Note: add real public/icons/icon-192.png and icon-512.png before a real install (placeholder step — generate via existing art scripts). The app runs without them; only the install icon is affected.
- Step 9: Commit
git add public/manifest.webmanifest public/sw.js index.html src/main.js tests/pwa.test.js
git commit -m "feat: installable offline PWA (manifest + service worker)"
Self-Review
Spec coverage (§ of design doc → task):
- §2 offline / no-server → Task 12 (SW + manifest); network features correctly out of Slice 1.
- §3 PWA + Web Components + no runtime framework → Tasks 1, 9–12.
- §3 component model (Shell / Rail / Tray / Dice engine / Scenario) → Tasks 11 / 9 / (10 + break-timer etc. deferred within tray shell) / 5–6 / 7.
- §4 scenario data model (relative-minute timeline, reserved sections, one system) → Tasks 7 (data + validator), 4 (consumes timeline).
- §5 Rail core (clock, you-are-here + drift, next hard trigger, Reached-it) → Tasks 3, 4, 8, 9, 11.
- §6 dice core + rule-packs, shaped/coloured dice, success ≠ colour → Tasks 5, 6, 10, 11 (styles).
- §9 persistence + export/import + SW precache → Tasks 2, 11, 12.
Deferred by design (not gaps): NPC generator (§7), Art generator (§8), the remaining tray tools (break timer, parking-lot note, jump/correct beat, wake-lock), additional rule-packs, and the clue safety-net are Slice 2+. The tray in Slice 1 ships with the Dice tool only; the rail exposes one tray chip. This matches design §10.
Placeholder scan: the only "placeholder" is the real-PNG icons note in Task 12 Step 8, which is explicitly a manual asset step with a working fallback — not a code placeholder.
Type consistency: analyze return shape (currentBeat/nextBeat/nextHardTrigger/minutesToNextHard/driftMin/cutHint) is used identically in Tasks 4, 9. rollPool → {results,total} and interpret(results) → {successes,ones,canPush,isCritical} match across Tasks 5, 6, 10. createStore API matches across Tasks 2, 11. <director-rail>.update({scenario,elapsedMs,stamps}) and its reached / open-tool events match across Tasks 9, 11. <dice-tray> systemId/sides/count/rng/roll()/last match across Tasks 10, 11.