diff --git a/docs/superpowers/plans/2026-07-20-director-slice2.md b/docs/superpowers/plans/2026-07-20-director-slice2.md new file mode 100644 index 0000000..f1eb1f8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-director-slice2.md @@ -0,0 +1,1025 @@ +# The Director — Slice 2 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 two offline tray tools to the shell — an NPC generator (genre tables) and a pencil-art library (search + fullscreen prop) — wired into the Director Rail's tray alongside dice. + +**Architecture:** Same vanilla ES-module Web Components over pure-logic cores. NPC generation is a pure function over a genre "table pack" with an injected `rng`; art search is a pure ranking function over a tagged manifest. Two new tray components (``, ``) plus a fullscreen `` overlay, all mounted and coordinated by ``; the Rail gains NPC and Art tray chips. + +**Tech Stack:** JavaScript (ES modules, no TypeScript), Web Components (light DOM), Vite, Vitest + jsdom. No new dependencies. Fully offline — no network features in this slice. + +## Global Constraints + +- **Offline-first:** every feature in this slice works with no server and no network. The online art "Generate" and online NPC "polish" are explicitly OUT of this slice. +- **No runtime framework:** custom elements only; only dev deps (vite, vitest, jsdom). +- **Determinism in logic:** no `Math.random()` / `Date.now()` inside `src/npc` or `src/art` — callers inject `rng` (`() => [0,1)`). +- **Persistence namespace:** scenario state uses namespace = `meta.id`; keys are `gmd..`. This slice adds the key `cast` (kept NPCs). +- **Web Components use light DOM (no shadow root).** Tray components are hidden by default (`hidden` attribute), toggled by `` on the Rail's `open-tool` event. +- **Tray tool events bubble to the shell:** `` already listens for `open-tool` (detail `{ tool }`). This slice adds tools `'npc'` and `'art'` and new bubbling events `keep-npc` and `show-prop`. +- **Test env:** Vitest `environment: 'jsdom'`; a jsdom-backed `localStorage` shim is already installed via `tests/setup.js`. +- **Node:** ≥ 18. + +**Existing interfaces this slice builds on (from Slice 1):** +- `src/core/store.js` → `createStore(namespace)` → `{ get(key, fallback), set, remove, exportAll, importAll }`. +- `src/components/gm-shell.js` → ``: has `this.store`, `this.scenario`, `loadScenario(scenario)`, a `
` region, listens for `open-tool` (currently toggles `.hidden` when `tool==='dice'`). +- `src/components/director-rail.js` → ``: renders a `.rail` with cells + a `data-role="open-dice"` chip that emits `open-tool` `{ tool:'dice' }`. +- `src/scenarios/afterimage.js` → `meta.system==='year-zero'`, `meta.id==='afterimage'`. + +--- + +### Task 1: NPC genre table pack (Noir) + registry + +**Files:** +- Create: `src/npc/packs/noir.js` +- Create: `src/npc/packs/index.js` +- Test: `tests/npc/packs.test.js` + +**Interfaces:** +- Produces: + - `noir` — `{ id: 'noir', label: 'Noir / Blade Runner', tables }` where `tables` has arrays: `firstNames`, `surnames`, `looks`, `manners`, `wants`, `secrets`, `voices` (each a non-empty array of strings). + - `getGenrePack(id)` → pack or `null`; `listGenrePacks()` → array of `{ id, label }`. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/npc/packs.test.js +import { describe, it, expect } from 'vitest'; +import { noir } from '../../src/npc/packs/noir.js'; +import { getGenrePack, listGenrePacks } from '../../src/npc/packs/index.js'; + +const FIELDS = ['firstNames', 'surnames', 'looks', 'manners', 'wants', 'secrets', 'voices']; + +describe('noir genre pack', () => { + it('has an id, label, and all non-empty tables', () => { + expect(noir.id).toBe('noir'); + expect(noir.label).toMatch(/noir/i); + for (const f of FIELDS) { + expect(Array.isArray(noir.tables[f]), `${f} is array`).toBe(true); + expect(noir.tables[f].length, `${f} non-empty`).toBeGreaterThanOrEqual(6); + expect(noir.tables[f].every((x) => typeof x === 'string' && x.length > 0)).toBe(true); + } + }); + + it('is resolvable from the registry', () => { + expect(getGenrePack('noir')).toBe(noir); + expect(getGenrePack('missing')).toBe(null); + expect(listGenrePacks()).toEqual([{ id: 'noir', label: noir.label }]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/npc/packs.test.js` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Write the noir pack** + +```js +// src/npc/packs/noir.js +export const noir = { + id: 'noir', + label: 'Noir / Blade Runner', + tables: { + firstNames: ['Otto', 'Mara', 'Cyrus', 'Dita', 'Lev', 'Priya', 'Sol', 'Neena', 'Bram', 'Vesna'], + surnames: ['Brandt', 'Okoye', 'Vance', 'Marlow', 'Sato', 'Kessler', 'Reyes', 'Dahl', 'Cole', 'Nyx'], + looks: [ + 'Heavyset, oil-stained cuffs, a rosary they keep touching', + 'Rail-thin, chrome tooth, coat two sizes too big', + 'Ex-military posture gone to seed, nicotine-yellow fingers', + 'Immaculate suit, one cracked lens in their glasses', + 'Burn scar down one wrist, always half in shadow', + 'Too-bright dye job, roots showing, tired eyes', + 'Synthetic-smooth skin that never quite reads as alive', + 'Missing a fingertip, hides the hand when they notice you looking', + ], + manners: [ + 'Over-friendly, laughs a beat too late', + 'Answers questions with questions', + 'Won’t sit with their back to the door', + 'Counts their words like they cost money', + 'Flatters you, then watches to see if it landed', + 'Keeps checking a watch that isn’t there', + 'Talks fast when lying, slow when it’s worse', + ], + wants: [ + 'The debt gone before their shift ends', + 'A name — someone else to take the fall', + 'Out of the city on the last transport', + 'To be believed, just once', + 'The photograph back before anyone else sees it', + 'Protection they’re too proud to ask for', + ], + secrets: [ + 'Signed off the container they never inspected', + 'Is already talking to the other side', + 'The person you’re looking for is family', + 'Sold the access codes months ago', + 'Isn’t who their papers say they are', + 'Watched it happen and did nothing', + ], + voices: [ + 'Says “you understand me?” after everything', + 'Never finishes the last word of a sentence', + 'Drops into a second language when rattled', + 'Calls everyone “friend” like a threat', + 'Over-enunciates, like reciting', + 'Laughs at their own lines before they land', + ], + }, +}; +``` + +- [ ] **Step 4: Write the registry** + +```js +// src/npc/packs/index.js +import { noir } from './noir.js'; + +const packs = new Map([[noir.id, noir]]); + +export function getGenrePack(id) { return packs.get(id) || null; } +export function registerGenrePack(pack) { packs.set(pack.id, pack); } +export function listGenrePacks() { + return [...packs.values()].map((p) => ({ id: p.id, label: p.label })); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/npc/packs.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/npc/packs/ tests/npc/packs.test.js +git commit -m "feat: NPC noir genre table pack + genre registry" +``` + +--- + +### Task 2: NPC generator (pure) + +**Files:** +- Create: `src/npc/generator.js` +- Test: `tests/npc/generator.test.js` + +**Interfaces:** +- Consumes: a genre pack `{ id, label, tables }` (Task 1). +- Produces: `generateNpc(pack, seed, rng)` → `{ genre, seed, name, look, manner, wants, secret, voice }`. `seed` is a freeform string echoed back verbatim (trimmed). Each of `look/manner/wants/secret/voice` is one entry picked from the matching table via `rng`; `name` is `" "`. Picking uses `pick(arr, rng) = arr[Math.floor(rng() * arr.length)]`. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/npc/generator.test.js +import { describe, it, expect } from 'vitest'; +import { generateNpc } from '../../src/npc/generator.js'; + +const pack = { + id: 'test', label: 'Test', + tables: { + firstNames: ['A', 'B'], surnames: ['X', 'Y'], + looks: ['look0', 'look1'], manners: ['man0', 'man1'], + wants: ['want0', 'want1'], secrets: ['sec0', 'sec1'], voices: ['voi0', 'voi1'], + }, +}; +// deterministic rng replaying a fixed sequence +function seq(values) { let i = 0; return () => values[i++ % values.length]; } + +describe('generateNpc', () => { + it('assembles a character by picking from tables with the injected rng', () => { + // order of picks: firstName, surname, look, manner, wants, secret, voice + const rng = seq([0.0, 0.9, 0.9, 0.0, 0.9, 0.0, 0.9]); + const npc = generateNpc(pack, ' nervous foreman ', rng); + expect(npc.name).toBe('A Y'); // 0.0->A, 0.9->Y + expect(npc.look).toBe('look1'); // 0.9 + expect(npc.manner).toBe('man0'); // 0.0 + expect(npc.wants).toBe('want1'); // 0.9 + expect(npc.secret).toBe('sec0'); // 0.0 + expect(npc.voice).toBe('voi1'); // 0.9 + expect(npc.seed).toBe('nervous foreman'); // trimmed, echoed + expect(npc.genre).toBe('Test'); + }); + + it('is fully determined by the rng (same sequence -> same npc)', () => { + const a = generateNpc(pack, 's', seq([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7])); + const b = generateNpc(pack, 's', seq([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7])); + expect(a).toEqual(b); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/npc/generator.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the generator** + +```js +// src/npc/generator.js +function pick(arr, rng) { + return arr[Math.floor(rng() * arr.length)]; +} + +export function generateNpc(pack, seed, rng) { + const t = pack.tables; + const name = `${pick(t.firstNames, rng)} ${pick(t.surnames, rng)}`; + return { + genre: pack.label, + seed: String(seed ?? '').trim(), + name, + look: pick(t.looks, rng), + manner: pick(t.manners, rng), + wants: pick(t.wants, rng), + secret: pick(t.secrets, rng), + voice: pick(t.voices, rng), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/npc/generator.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/npc/generator.js tests/npc/generator.test.js +git commit -m "feat: pure NPC generator over genre tables" +``` + +--- + +### Task 3: `` component + +**Files:** +- Create: `src/components/npc-tray.js` +- Test: `tests/components/npc-tray.test.js` + +**Interfaces:** +- Consumes: `generateNpc` (Task 2), `getGenrePack`/`listGenrePacks` (Task 1). +- Produces: custom element `` with settable `rng` (default `Math.random`) and `genreId` (default `'noir'`). Method `generate()` reads the seed input value, calls `generateNpc(getGenrePack(this.genreId), seed, this.rng)`, stores `this.last`, and re-renders. Elements: seed input `[data-role="seed"]`, generate button `[data-role="generate"]`, reroll button `[data-role="reroll"]` (also calls `generate()`), keep button `[data-role="keep"]`, and the rendered card in `[data-role="card"]` containing the npc name in `[data-role="npc-name"]`. Clicking keep emits bubbling `CustomEvent('keep-npc', { detail: this.last })`. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/components/npc-tray.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/npc-tray.js'; + +function seq(values) { let i = 0; return () => values[i++ % values.length]; } + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('npc-tray'); + document.body.appendChild(el); + }); + + it('renders a seed input and a generate button', () => { + expect(el.querySelector('[data-role=seed]')).not.toBe(null); + expect(el.querySelector('[data-role=generate]')).not.toBe(null); + }); + + it('generates an NPC card from the seed using the noir pack', () => { + el.rng = seq([0.0]); // every pick -> index 0 + el.querySelector('[data-role=seed]').value = 'dock foreman'; + el.querySelector('[data-role=generate]').click(); + expect(el.last.seed).toBe('dock foreman'); + expect(el.last.genre).toMatch(/noir/i); + expect(el.querySelector('[data-role=card]')).not.toBe(null); + expect(el.querySelector('[data-role=npc-name]').textContent.length).toBeGreaterThan(0); + }); + + it('emits keep-npc with the generated npc when Keep is clicked', () => { + el.rng = seq([0.0]); + el.querySelector('[data-role=seed]').value = 'x'; + el.querySelector('[data-role=generate]').click(); + let detail = null; + el.addEventListener('keep-npc', (e) => { detail = e.detail; }); + el.querySelector('[data-role=keep]').click(); + expect(detail).not.toBe(null); + expect(detail.name).toBe(el.last.name); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/components/npc-tray.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the component** + +```js +// src/components/npc-tray.js +import { generateNpc } from '../npc/generator.js'; +import { getGenrePack, listGenrePacks } from '../npc/packs/index.js'; + +export class NpcTray extends HTMLElement { + constructor() { + super(); + this.rng = Math.random; + this.genreId = 'noir'; + this.last = null; + this._seedValue = ''; + } + connectedCallback() { this.render(); } + + generate() { + const input = this.querySelector('[data-role=seed]'); + this._seedValue = input ? input.value : ''; + const pack = getGenrePack(this.genreId); + this.last = generateNpc(pack, this._seedValue, this.rng); + this.render(); + } + + render() { + const pills = listGenrePacks().map((p) => + ``).join(''); + const n = this.last; + const card = n ? ` +
+
${n.name}
+
${n.seed}
+
+
Look
${n.look}
+
Manner
${n.manner}
+
Wants
${n.wants}
+
Secret
${n.secret}
+
Voice
${n.voice}
+
+
+ + +
+
` : ''; + + this.innerHTML = ` +
+
${pills}
+ + + ${card} +
`; + + this.querySelectorAll('[data-genre]').forEach((b) => + b.addEventListener('click', () => { this.genreId = b.dataset.genre; this.render(); })); + this.querySelector('[data-role=generate]').addEventListener('click', () => this.generate()); + this.querySelector('[data-role=reroll]')?.addEventListener('click', () => this.generate()); + this.querySelector('[data-role=keep]')?.addEventListener('click', () => { + if (this.last) this.dispatchEvent(new CustomEvent('keep-npc', { detail: this.last, bubbles: true })); + }); + } +} +customElements.define('npc-tray', NpcTray); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/components/npc-tray.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/npc-tray.js tests/components/npc-tray.test.js +git commit -m "feat: seed-to-character generator UI" +``` + +--- + +### Task 4: Art library search (pure) + +**Files:** +- Create: `src/art/search.js` +- Test: `tests/art/search.test.js` + +**Interfaces:** +- Produces: `searchArt(manifest, query)` → array of matching assets, ranked by number of query terms matched (descending), ties broken by the asset's original manifest order. `manifest` is `[{ id, src, tags: [string], label }]`. A `query` is split on whitespace into lowercased terms; an asset matches a term if the term is a substring of any of its `tags` or its `label` (case-insensitive). An empty/blank query returns the full manifest in original order. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/art/search.test.js +import { describe, it, expect } from 'vitest'; +import { searchArt } from '../../src/art/search.js'; + +const M = [ + { id: 'a', src: 'a.png', label: 'Detective in alley', tags: ['detective', 'alley', 'rain'] }, + { id: 'b', src: 'b.png', label: 'Neon street', tags: ['street', 'neon', 'rain'] }, + { id: 'c', src: 'c.png', label: 'Worn portrait', tags: ['portrait', 'face'] }, +]; + +describe('searchArt', () => { + it('returns the whole manifest for a blank query, in order', () => { + expect(searchArt(M, '').map((x) => x.id)).toEqual(['a', 'b', 'c']); + expect(searchArt(M, ' ').map((x) => x.id)).toEqual(['a', 'b', 'c']); + }); + + it('ranks by number of matched terms', () => { + // "rain alley" -> a matches both (2), b matches rain (1), c matches none + expect(searchArt(M, 'rain alley').map((x) => x.id)).toEqual(['a', 'b']); + }); + + it('matches substrings in tags or label, case-insensitive', () => { + expect(searchArt(M, 'DETECT').map((x) => x.id)).toEqual(['a']); + expect(searchArt(M, 'portrait').map((x) => x.id)).toEqual(['c']); + }); + + it('breaks rank ties by original manifest order', () => { + // "rain" -> a and b both match once; a comes first in manifest + expect(searchArt(M, 'rain').map((x) => x.id)).toEqual(['a', 'b']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/art/search.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the search** + +```js +// src/art/search.js +export function searchArt(manifest, query) { + const terms = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean); + if (terms.length === 0) return [...manifest]; + + const scored = manifest.map((asset, index) => { + const hay = [asset.label, ...(asset.tags || [])].join(' ').toLowerCase(); + const score = terms.reduce((s, term) => s + (hay.includes(term) ? 1 : 0), 0); + return { asset, index, score }; + }); + + return scored + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map((r) => r.asset); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/art/search.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/art/search.js tests/art/search.test.js +git commit -m "feat: pure tag-ranked art library search" +``` + +--- + +### Task 5: Art manifest (seed data) + validator + +**Files:** +- Create: `src/art/manifest.js` +- Test: `tests/art/manifest.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `artManifest` — default export, an array of `{ id, src, label, tags: [string] }` (a small seed set of placeholder pencil assets; `src` paths under `/art/`). + - `validateArtManifest(m)` → array of error strings (empty = valid): each entry needs a non-empty `id`, a non-empty `src`, and a `tags` array. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/art/manifest.test.js +import { describe, it, expect } from 'vitest'; +import artManifest, { validateArtManifest } from '../../src/art/manifest.js'; + +describe('art manifest', () => { + it('flags malformed entries', () => { + expect(validateArtManifest([{ id: '', src: 'x', tags: [] }])).toContain('entry[0].id required'); + expect(validateArtManifest([{ id: 'a', src: '', tags: [] }])).toContain('entry[0].src required'); + expect(validateArtManifest([{ id: 'a', src: 'x' }])).toContain('entry[0].tags must be an array'); + }); + + it('ships a valid, non-empty seed manifest with unique ids', () => { + expect(validateArtManifest(artManifest)).toEqual([]); + expect(artManifest.length).toBeGreaterThanOrEqual(3); + const ids = artManifest.map((a) => a.id); + expect(new Set(ids).size).toBe(ids.length); + expect(artManifest.every((a) => a.src.startsWith('/art/'))).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/art/manifest.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the manifest + validator** + +```js +// src/art/manifest.js +export function validateArtManifest(m) { + if (!Array.isArray(m)) return ['manifest must be an array']; + const errors = []; + m.forEach((a, i) => { + if (!a || typeof a !== 'object') { errors.push(`entry[${i}] must be an object`); return; } + if (!a.id) errors.push(`entry[${i}].id required`); + if (!a.src) errors.push(`entry[${i}].src required`); + if (!Array.isArray(a.tags)) errors.push(`entry[${i}].tags must be an array`); + }); + return errors; +} + +// Seed set of placeholder pencil assets. Real convention art is added here as data. +export default [ + { id: 'detective-alley', src: '/art/detective-alley.png', label: 'Detective in alley', tags: ['detective', 'alley', 'rain', 'coat', 'tired'] }, + { id: 'neon-street', src: '/art/neon-street.png', label: 'Neon street', tags: ['street', 'neon', 'rain', 'city', 'night'] }, + { id: 'worn-portrait', src: '/art/worn-portrait.png', label: 'Worn portrait', tags: ['portrait', 'face', 'worn', 'close-up'] }, + { id: 'doorway-figure', src: '/art/doorway-figure.png', label: 'Figure in a doorway', tags: ['doorway', 'figure', 'shadow', 'coat'] }, + { id: 'rain-pier', src: '/art/rain-pier.png', label: 'Pier in the rain', tags: ['pier', 'rain', 'water', 'night', 'wide'] }, +]; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/art/manifest.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/art/manifest.js tests/art/manifest.test.js +git commit -m "feat: seed pencil-art manifest + validator" +``` + +--- + +### Task 6: `` fullscreen overlay + +**Files:** +- Create: `src/components/prop-viewer.js` +- Test: `tests/components/prop-viewer.test.js` + +**Interfaces:** +- Produces: custom element `` with methods `show(src, label)` (renders a fullscreen overlay with an `` at `data-role="prop-img"`, removes `hidden`) and `dismiss()` (adds `hidden`). Starts hidden. Clicking the overlay root (`[data-role="overlay"]`) calls `dismiss()`. Exposes `isOpen()` → boolean (`!hidden`). + +- [ ] **Step 1: Write the failing test** + +```js +// tests/components/prop-viewer.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/prop-viewer.js'; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('prop-viewer'); + document.body.appendChild(el); + }); + + it('starts hidden', () => { + expect(el.isOpen()).toBe(false); + expect(el.hasAttribute('hidden')).toBe(true); + }); + + it('shows an image when show() is called', () => { + el.show('/art/rain-pier.png', 'Pier in the rain'); + expect(el.isOpen()).toBe(true); + const img = el.querySelector('[data-role=prop-img]'); + expect(img.getAttribute('src')).toBe('/art/rain-pier.png'); + }); + + it('dismisses when the overlay is clicked', () => { + el.show('/art/x.png', 'x'); + el.querySelector('[data-role=overlay]').click(); + expect(el.isOpen()).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/components/prop-viewer.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the component** + +```js +// src/components/prop-viewer.js +export class PropViewer extends HTMLElement { + constructor() { super(); this._src = ''; this._label = ''; } + connectedCallback() { if (!this.hasAttribute('hidden')) this.setAttribute('hidden', ''); this.render(); } + + isOpen() { return !this.hasAttribute('hidden'); } + + show(src, label = '') { + this._src = src; this._label = label; + this.removeAttribute('hidden'); + this.render(); + } + dismiss() { this.setAttribute('hidden', ''); } + + render() { + this.innerHTML = ` +
+ ${this._label} +
`; + this.querySelector('[data-role=overlay]').addEventListener('click', () => this.dismiss()); + } +} +customElements.define('prop-viewer', PropViewer); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/components/prop-viewer.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/prop-viewer.js tests/components/prop-viewer.test.js +git commit -m "feat: fullscreen prop overlay" +``` + +--- + +### Task 7: `` component + +**Files:** +- Create: `src/components/art-tray.js` +- Test: `tests/components/art-tray.test.js` + +**Interfaces:** +- Consumes: `searchArt` (Task 4), `artManifest` (Task 5). +- Produces: custom element `` with settable `manifest` (default `artManifest`). Renders a search input `[data-role="art-query"]` and a results grid `[data-role="art-results"]` of thumbnails, each a `[data-art-id]` element. Typing in the input (an `input` event) re-renders results via `searchArt(this.manifest, query)`. Clicking a thumbnail emits bubbling `CustomEvent('show-prop', { detail: { src, label } })`. Initial render (blank query) shows the full manifest. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/components/art-tray.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/art-tray.js'; + +const M = [ + { id: 'a', src: '/art/a.png', label: 'Detective in alley', tags: ['detective', 'alley'] }, + { id: 'b', src: '/art/b.png', label: 'Neon street', tags: ['street', 'neon'] }, +]; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('art-tray'); + el.manifest = M; + document.body.appendChild(el); + }); + + it('shows the whole manifest before any search', () => { + expect(el.querySelectorAll('[data-art-id]').length).toBe(2); + }); + + it('filters results as the query changes', () => { + const input = el.querySelector('[data-role=art-query]'); + input.value = 'detective'; + input.dispatchEvent(new Event('input')); + const ids = [...el.querySelectorAll('[data-art-id]')].map((n) => n.dataset.artId); + expect(ids).toEqual(['a']); + }); + + it('emits show-prop when a thumbnail is clicked', () => { + let detail = null; + el.addEventListener('show-prop', (e) => { detail = e.detail; }); + el.querySelector('[data-art-id="b"]').click(); + expect(detail).toEqual({ src: '/art/b.png', label: 'Neon street' }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/components/art-tray.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the component** + +```js +// src/components/art-tray.js +import { searchArt } from '../art/search.js'; +import artManifest from '../art/manifest.js'; + +export class ArtTray extends HTMLElement { + constructor() { + super(); + this.manifest = artManifest; + this._query = ''; + } + connectedCallback() { this.render(); } + + render() { + const results = searchArt(this.manifest, this._query); + const grid = results.map((a) => ` + `).join(''); + + this.innerHTML = ` +
+ +
${grid || '
No matches
'}
+
`; + + const input = this.querySelector('[data-role=art-query]'); + input.addEventListener('input', () => { this._query = input.value; this.renderResults(); }); + this.bindThumbs(); + } + + // Re-render only the results (keeps input focus/caret stable while typing). + renderResults() { + const results = searchArt(this.manifest, this._query); + const container = this.querySelector('[data-role=art-results]'); + container.innerHTML = results.map((a) => ` + `).join('') || '
No matches
'; + this.bindThumbs(); + } + + bindThumbs() { + this.querySelectorAll('[data-art-id]').forEach((btn) => { + btn.addEventListener('click', () => { + const asset = this.manifest.find((a) => a.id === btn.dataset.artId); + if (asset) this.dispatchEvent(new CustomEvent('show-prop', { detail: { src: asset.src, label: asset.label }, bubbles: true })); + }); + }); + } +} +customElements.define('art-tray', ArtTray); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/components/art-tray.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/art-tray.js tests/components/art-tray.test.js +git commit -m "feat: pencil-art library search UI" +``` + +--- + +### Task 8: Wire NPC + Art into `` and the Rail + +**Files:** +- Modify: `src/components/gm-shell.js` +- Modify: `src/components/director-rail.js` +- Modify: `src/styles.css` +- Test: `tests/components/gm-shell-slice2.test.js` + +**Interfaces:** +- Consumes: `` (Task 3), `` (Task 7), `` (Task 6), existing `createStore`. +- Produces: `` mounts `