From bc8e23c5f6831d7b818504e2a9b8524e5092b147 Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Mon, 20 Jul 2026 16:43:19 +0100 Subject: [PATCH] Plan: The Director Slice 4 (clue safety-net) Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-20-director-slice4.md | 462 ++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-director-slice4.md diff --git a/docs/superpowers/plans/2026-07-20-director-slice4.md b/docs/superpowers/plans/2026-07-20-director-slice4.md new file mode 100644 index 0000000..7a6511f --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-director-slice4.md @@ -0,0 +1,462 @@ +# The Director β€” Slice 4 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:** Add the clue safety-net β€” a tray tool that, from a scenario's `clues[]`, shows at a glance which **essential** clues are still missing to reach an ending and which fallback delivers each, so the mystery never becomes unsolvable at the table. + +**Architecture:** Same pattern as the other tools β€” a pure engine (`analyzeClues`) that computes solvability from clues + a revealed-id set, a `` light-DOM component that renders the solvability banner + toggleable clue list + fallbacks, and shell/rail wiring that persists the revealed set and adds a πŸ” chip. No real convention clue content is fabricated: the engine reads `scenario.clues` (empty for shipped scenarios), and a clearly-labelled example scenario file provides a template + test fixture. + +**Tech Stack:** JavaScript (ES modules, no TypeScript), Web Components (light DOM), Vite, Vitest + jsdom. No new dependencies. Fully offline. + +## Global Constraints + +- **Offline-first, no runtime framework, light DOM, no TypeScript.** +- **Clue model:** a clue is `{ id, label, essential (bool), act? (string), routes? ([string]), fallback? (string) }`. `essential` marks clues required to reach an ending; `fallback` is how to deliver it if missed. +- **Determinism:** the engine is pure β€” it takes `(clues, revealed)` and returns a result; no globals. +- **Persistence:** the revealed-clue id list persists under store key `revealedClues` (namespace = `meta.id`), consistent with `stamps`/`cast`. +- **Escaping:** clue `label`/`fallback`/`act` are interpolated via the shared `escapeHtml` (`src/core/escape-html.js`) β€” they may be author free text. +- **Tray integration:** `` is a tray tool, hidden by default, toggled by the shell on `open-tool` with `tool: 'clues'`; the Rail gains a `data-role="open-clues"` chip. +- **No fabricated content:** shipped scenarios keep `clues: []`; the example lives in a file named to make clear it is a template, not a convention scenario. +- **Test env:** Vitest `environment: 'jsdom'`; `tests/setup.js` shim present. **Node β‰₯ 18.** + +**Existing interfaces this slice builds on:** +- `src/core/store.js` β†’ `createStore(namespace)`. +- `src/core/escape-html.js` β†’ `escapeHtml(s)`. +- `src/components/gm-shell.js` β†’ ``: `this.store`, `this.scenario`, `loadScenario`, `
`, `onOpenTool` toggles trays via a `{dice,npc,art}` map; listens for `open-tool`; persists `stamps`/`cast`. +- `src/components/director-rail.js` β†’ renders tool chips `open-dice`/`open-npc`/`open-art` emitting `open-tool`. +- `src/scenarios/afterimage.js` β†’ default export with `clues: []`. + +--- + +### Task 1: Clue safety-net engine (pure) + +**Files:** +- Create: `src/clues/safety-net.js` +- Test: `tests/clues/safety-net.test.js` + +**Interfaces:** +- Produces: `analyzeClues(clues = [], revealed = [])` β†’ `{ total, revealedCount, essentialTotal, essentialRevealed, missingEssential, solvable }`. `revealed` is an array of clue ids. `missingEssential` is the array of essential clue objects not in `revealed` (each still carrying its `fallback`). `solvable` is `missingEssential.length === 0`. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/clues/safety-net.test.js +import { describe, it, expect } from 'vitest'; +import { analyzeClues } from '../../src/clues/safety-net.js'; + +const CLUES = [ + { id: 'c1', label: 'The door-cam still', essential: true, fallback: 'The radio names the time' }, + { id: 'c2', label: 'Priya’s phone', essential: true, fallback: 'The neighbour saw her leave' }, + { id: 'c3', label: 'A nice-to-have colour detail', essential: false }, +]; + +describe('analyzeClues', () => { + it('reports counts and solvability from the revealed set', () => { + const r = analyzeClues(CLUES, ['c1']); + expect(r.total).toBe(3); + expect(r.revealedCount).toBe(1); + expect(r.essentialTotal).toBe(2); + expect(r.essentialRevealed).toBe(1); + expect(r.solvable).toBe(false); + expect(r.missingEssential.map((c) => c.id)).toEqual(['c2']); + expect(r.missingEssential[0].fallback).toBe('The neighbour saw her leave'); + }); + + it('is solvable once every essential clue is revealed', () => { + const r = analyzeClues(CLUES, ['c1', 'c2']); + expect(r.solvable).toBe(true); + expect(r.missingEssential).toEqual([]); + }); + + it('treats an empty clue list as vacuously solvable', () => { + const r = analyzeClues([], []); + expect(r.solvable).toBe(true); + expect(r.essentialTotal).toBe(0); + expect(r.total).toBe(0); + }); + + it('defaults revealed to empty', () => { + expect(analyzeClues(CLUES).essentialRevealed).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/clues/safety-net.test.js` +Expected: FAIL β€” module not found. + +- [ ] **Step 3: Write the engine** + +```js +// src/clues/safety-net.js +export function analyzeClues(clues = [], revealed = []) { + const seen = new Set(revealed); + const essential = clues.filter((c) => c.essential); + const missingEssential = essential.filter((c) => !seen.has(c.id)); + return { + total: clues.length, + revealedCount: clues.filter((c) => seen.has(c.id)).length, + essentialTotal: essential.length, + essentialRevealed: essential.length - missingEssential.length, + missingEssential, + solvable: missingEssential.length === 0, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/clues/safety-net.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/clues/safety-net.js tests/clues/safety-net.test.js +git commit -m "feat: clue safety-net engine (essential-gap + solvability)" +``` + +--- + +### Task 2: `` component + +**Files:** +- Create: `src/components/clue-net.js` +- Test: `tests/components/clue-net.test.js` + +**Interfaces:** +- Consumes: `analyzeClues` (Task 1), `escapeHtml` (`src/core/escape-html.js`). +- Produces: custom element `` with settable `clues` (array, default `[]`) and `revealed` (array of ids, default `[]`), each re-rendering. Renders: a banner `[data-role="solvable"]` (text `Solvable βœ“` when solvable, else `N essential clue(s) still needed`), and one row per clue `[data-clue-id=""]` with a reveal toggle button `[data-role="toggle"]`, an essential badge when essential, a lit/`revealed` class when revealed, and β€” for a *missing essential* β€” its fallback in a `.clue-fallback`. Empty clues render `[data-role="empty"]`. Clicking a clue's toggle emits bubbling `CustomEvent('toggle-clue', { detail: { id } })`. All author text is escaped. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/components/clue-net.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/clue-net.js'; + +const CLUES = [ + { id: 'c1', label: 'Door-cam still', essential: true, fallback: 'Radio names the time' }, + { id: 'c2', label: 'Colour detail', essential: false }, +]; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('clue-net'); + document.body.appendChild(el); + }); + + it('shows an empty state with no clues', () => { + expect(el.querySelector('[data-role=empty]')).not.toBe(null); + }); + + it('renders a row per clue and a not-yet-solvable banner', () => { + el.clues = CLUES; + el.revealed = []; + expect(el.querySelectorAll('[data-clue-id]').length).toBe(2); + expect(el.querySelector('[data-role=solvable]').textContent).toContain('1 essential'); + // the missing essential shows its fallback + expect(el.querySelector('[data-clue-id="c1"] .clue-fallback').textContent).toContain('Radio names the time'); + }); + + it('flips to solvable once the essential clue is revealed', () => { + el.clues = CLUES; + el.revealed = ['c1']; + expect(el.querySelector('[data-role=solvable]').textContent).toContain('Solvable'); + // revealed essential no longer shows a fallback prompt + expect(el.querySelector('[data-clue-id="c1"] .clue-fallback')).toBe(null); + }); + + it('emits toggle-clue when a reveal toggle is clicked', () => { + el.clues = CLUES; el.revealed = []; + let detail = null; + el.addEventListener('toggle-clue', (e) => { detail = e.detail; }); + el.querySelector('[data-clue-id="c1"] [data-role=toggle]').click(); + expect(detail).toEqual({ id: 'c1' }); + }); + + it('escapes author text', () => { + el.clues = [{ id: 'x', label: 'evil" ', essential: true, fallback: 'fb" ' }]; + el.revealed = []; + const row = el.querySelector('[data-clue-id="x"]'); + expect(row.innerHTML).not.toContain(''); + expect(row.querySelector('.clue-fallback').innerHTML).not.toContain(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/components/clue-net.test.js` +Expected: FAIL β€” module not found. + +- [ ] **Step 3: Write the component** + +```js +// src/components/clue-net.js +import { analyzeClues } from '../clues/safety-net.js'; +import { escapeHtml } from '../core/escape-html.js'; + +export class ClueNet extends HTMLElement { + constructor() { + super(); + this._clues = []; + this._revealed = []; + } + connectedCallback() { this.render(); } + + set clues(v) { this._clues = Array.isArray(v) ? v : []; this.render(); } + get clues() { return this._clues; } + set revealed(v) { this._revealed = Array.isArray(v) ? v : []; this.render(); } + get revealed() { return this._revealed; } + + render() { + if (this._clues.length === 0) { + this.innerHTML = `
No clues configured for this scenario.
`; + return; + } + const a = analyzeClues(this._clues, this._revealed); + const seen = new Set(this._revealed); + const banner = a.solvable + ? 'Solvable βœ“' + : `${a.missingEssential.length} essential clue${a.missingEssential.length === 1 ? '' : 's'} still needed`; + + const rows = this._clues.map((c) => { + const revealed = seen.has(c.id); + const showFallback = c.essential && !revealed && c.fallback; + return ` +
+ +
+
${escapeHtml(c.label)}${c.essential ? ' essential' : ''}${c.act ? ` ${escapeHtml(c.act)}` : ''}
+ ${showFallback ? `
Fallback: ${escapeHtml(c.fallback)}
` : ''} +
+
`; + }).join(''); + + this.innerHTML = ` +
+
${banner}
+
${rows}
+
`; + + this.querySelectorAll('[data-clue-id]').forEach((row) => { + row.querySelector('[data-role=toggle]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('toggle-clue', { detail: { id: row.dataset.clueId }, bubbles: true })); + }); + }); + } +} +customElements.define('clue-net', ClueNet); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/components/clue-net.test.js` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/clue-net.js tests/components/clue-net.test.js +git commit -m "feat: essential-gap tracker component" +``` + +--- + +### Task 3: Wire the clue-net into the shell + rail (+ example scenario) + +**Files:** +- Modify: `src/components/gm-shell.js` +- Modify: `src/components/director-rail.js` +- Modify: `src/styles.css` +- Create: `src/scenarios/example-with-clues.js` +- Test: `tests/components/gm-shell-clues.test.js` + +**Interfaces:** +- Consumes: `` (Task 2), existing `createStore`. +- Produces: `` mounts `