From 9d0e6c7ec433a65c7d8311a35dbf9620910138bd Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:34:38 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20cast=20tray=20=E2=80=94=20surface=20sce?= =?UTF-8?q?nario.cast=20rosters=20in=20a=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add : renders scenario.cast (name + one-line note) with each NPC secret behind a tap-to-reveal so spoilers stay off-screen at the table. Wired into the shell as the 'cast' tool with a ๐Ÿ‘ฅ rail chip; reveal state is ephemeral component state. Now the 57 ported NPCs are visible in a running session. tests/components/cast-tray.test.js + gm-shell-cast.test.js. 141 tests pass. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- .../plans/2026-07-20-director-slice5.md | 7 ++- src/components/cast-tray.js | 50 ++++++++++++++++ src/components/director-rail.js | 4 ++ src/components/gm-shell.js | 5 +- src/styles.css | 13 ++++ tests/components/cast-tray.test.js | 60 +++++++++++++++++++ tests/components/gm-shell-cast.test.js | 42 +++++++++++++ 8 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/components/cast-tray.js create mode 100644 tests/components/cast-tray.test.js create mode 100644 tests/components/gm-shell-cast.test.js diff --git a/README.md b/README.md index 0090519..9355b10 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Components are dumb renderers that emit events; the shell wires them together. | `src/con/` | Convention data (`continuum-2026.js`; `example-con.js` is a fully-timed template) | | `src/components/director-rail.js` | `` โ€” the always-on pacing bar (๐ŸŽฒ ๐Ÿ‘ค โœ๏ธ ๐Ÿ” chips) | | `src/components/dice-tray.js` | `` โ€” pack selector + die picker + roll + verdict | -| `src/components/npc-tray.js` ยท `art-tray.js` ยท `prop-viewer.js` ยท `clue-net.js` | The tray tools | +| `src/components/npc-tray.js` ยท `art-tray.js` ยท `prop-viewer.js` ยท `clue-net.js` ยท `cast-tray.js` | The tray tools (`cast-tray` renders `scenario.cast` with tap-to-reveal secrets) | | `src/components/gm-shell.js` | `` โ€” root wiring + persistence | | `src/scenarios/` | Scenario data files (six Continuum slots: `afterimage`, `day-one`, `vain-crown`, `silvery-moon`, `chopper`, `princes-bride`; `example-with-clues.js` is a template); `index.js` is the registry | | `public/` | `manifest.webmanifest`, `sw.js` (offline service worker) | @@ -97,7 +97,7 @@ compress if you reach a beat behind schedule. 3. **Slice 3** โ€” dice rule-packs (CoC d100, VANITY d6-pool, Panic & Glory, Dee Sanction) + tray pack selector. โœ… 4. **Slice 4** โ€” the clue **safety-net** (essential-clue gap tracker + fallbacks). โœ… 5. **Slice 5** โ€” the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). โœ… All **six** Continuum 2026 slots ported and scheduled with real times. -6. **Later** โ€” remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; a cast-dashboard UI to surface the ported `scenario.cast` rosters; a dedicated BRP dice pack (Day One currently borrows the d100 pack); markdown โ†’ scenario-data generator; native iPad wrapper. +6. **Later** โ€” remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; a dedicated BRP dice pack (Day One currently borrows the d100 pack); markdown โ†’ scenario-data generator; native iPad wrapper. ## Docs diff --git a/docs/superpowers/plans/2026-07-20-director-slice5.md b/docs/superpowers/plans/2026-07-20-director-slice5.md index 0b5b8a9..2ad7ba3 100644 --- a/docs/superpowers/plans/2026-07-20-director-slice5.md +++ b/docs/superpowers/plans/2026-07-20-director-slice5.md @@ -65,7 +65,12 @@ jsdom. No new dependencies. Fully offline. Note: the `cast` field has no UI consumer yet (the design reserves it for a future cast dashboard) โ€” this is a data-model port. Pregens/PCs are intentionally excluded (separate character-sheet/handout system). +- **Cast tray built.** `` (`src/components/cast-tray.js`) renders `scenario.cast` โ€” name + + one-line note, each secret behind a tap-to-reveal โ€” wired into the shell (`cast` tool key) with a + ๐Ÿ‘ฅ rail chip. Reveal state is ephemeral component state. `tests/components/cast-tray.test.js` + + `gm-shell-cast.test.js` cover it. + ## Still deferred (backlog) -- A **cast dashboard** UI to surface `scenario.cast` (parallels ``). - A dedicated **BRP** dice pack โ€” Day One currently borrows `coc-d100` (d100 roll-under). +- Break-timer / parking-lot / wake-lock tray tools; online art "Generate". diff --git a/src/components/cast-tray.js b/src/components/cast-tray.js new file mode 100644 index 0000000..5993a13 --- /dev/null +++ b/src/components/cast-tray.js @@ -0,0 +1,50 @@ +import { escapeHtml } from '../core/escape-html.js'; + +// The cast roster tray. Renders scenario.cast (NPCs): name + one-line note, +// with each secret hidden behind a tap-to-reveal so spoilers stay off-screen +// at the table. Reveal state is ephemeral component state (not persisted). +export class CastTray extends HTMLElement { + constructor() { + super(); + this._cast = []; + this._revealed = new Set(); + } + connectedCallback() { this.render(); } + + set cast(v) { this._cast = Array.isArray(v) ? v : []; this._revealed = new Set(); this.render(); } + get cast() { return this._cast; } + + render() { + if (this._cast.length === 0) { + this.innerHTML = `
No cast configured for this scenario.
`; + return; + } + const rows = this._cast.map((c) => { + const revealed = this._revealed.has(c.id); + const hasSecret = !!c.secret; + return ` +
+
+
${escapeHtml(c.name)}${c.kind && c.kind !== 'npc' ? ` ${escapeHtml(c.kind)}` : ''}
+ ${c.note ? `
${escapeHtml(c.note)}
` : ''} + ${hasSecret && revealed ? `
${escapeHtml(c.secret)}
` : ''} +
+ ${hasSecret ? `` : ''} +
`; + }).join(''); + + this.innerHTML = `
${rows}
`; + + this.querySelectorAll('[data-cast-id]').forEach((row) => { + const btn = row.querySelector('[data-role=reveal]'); + if (!btn) return; + btn.addEventListener('click', () => { + const id = row.dataset.castId; + if (this._revealed.has(id)) this._revealed.delete(id); + else this._revealed.add(id); + this.render(); + }); + }); + } +} +customElements.define('cast-tray', CastTray); diff --git a/src/components/director-rail.js b/src/components/director-rail.js index 68cf907..627debf 100644 --- a/src/components/director-rail.js +++ b/src/components/director-rail.js @@ -31,6 +31,7 @@ export class DirectorRail extends HTMLElement { + `; this.querySelector('[data-role=reached]').addEventListener('click', () => { @@ -51,6 +52,9 @@ export class DirectorRail extends HTMLElement { this.querySelector('[data-role=open-clues]').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'clues' }, bubbles: true })); }); + this.querySelector('[data-role=open-cast]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'cast' }, bubbles: true })); + }); } } customElements.define('director-rail', DirectorRail); diff --git a/src/components/gm-shell.js b/src/components/gm-shell.js index 2e2859e..9f5b3d1 100644 --- a/src/components/gm-shell.js +++ b/src/components/gm-shell.js @@ -7,6 +7,7 @@ import './npc-tray.js'; import './art-tray.js'; import './prop-viewer.js'; import './clue-net.js'; +import './cast-tray.js'; import './con-hub.js'; import { getScenario } from '../scenarios/index.js'; @@ -33,6 +34,7 @@ export class GmShell extends HTMLElement { + `; this.addEventListener('reached', () => this.onReached()); @@ -82,6 +84,7 @@ export class GmShell extends HTMLElement { const net = this.querySelector('clue-net'); net.clues = this.scenario.clues || []; net.revealed = this.revealedClues; + this.querySelector('cast-tray').cast = this.scenario.cast || []; this.refresh(); } @@ -104,7 +107,7 @@ export class GmShell extends HTMLElement { onOpenTool(e) { const tool = e.detail?.tool; - const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net' }; + const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net', cast: 'cast-tray' }; if (!(tool in trays)) return; const target = this.querySelector(trays[tool]); const willShow = target.hidden; diff --git a/src/styles.css b/src/styles.css index 82bcb5e..7fa5596 100644 --- a/src/styles.css +++ b/src/styles.css @@ -107,3 +107,16 @@ director-rail[hidden] { display: none; } .hub-open { border: none; padding: 10px 20px; border-radius: 9px; background: #1c8a5a; color: #eafff4; font-weight: 700; font-size: 14px; flex: 0 0 auto; } .hub-soon { font-size: 11px; color: #6b7a8d; flex: 0 0 auto; } + +/* Cast roster tray */ +.cast-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; max-height: 100%; overflow-y: auto; } +cast-tray[hidden] { display: none; } +.cast-list { display: flex; flex-direction: column; gap: 6px; } +.cast-row { display: flex; gap: 10px; align-items: flex-start; padding: 9px 11px; border: 1px solid #263041; border-radius: 8px; } +.cast-body { flex: 1; min-width: 0; } +.cast-name { font-size: 13px; font-weight: 700; color: #e7eef6; } +.cast-kind { font-size: 9px; text-transform: uppercase; letter-spacing: .08em; color: #9fb0c2; border: 1px solid #2a3542; border-radius: 10px; padding: 1px 6px; } +.cast-note { font-size: 12px; color: #9fb0c2; margin-top: 3px; } +.cast-secret { font-size: 12px; color: #ffd479; margin-top: 6px; padding-top: 6px; border-top: 1px dashed #2b3644; } +.cast-reveal { flex: 0 0 auto; border: 1px solid #2a3542; background: #161d27; color: #cfe0f0; font-size: 11px; font-weight: 700; border-radius: 8px; padding: 6px 12px; } +.cast-empty { color: #6b7a8d; font-size: 12px; } diff --git a/tests/components/cast-tray.test.js b/tests/components/cast-tray.test.js new file mode 100644 index 0000000..d335b9a --- /dev/null +++ b/tests/components/cast-tray.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/cast-tray.js'; + +const CAST = [ + { id: 'a', name: 'MARY FLETCHER', kind: 'npc', note: 'Innkeeper; saw the watchers', secret: 'She saw Crowe sew the pendant' }, + { id: 'b', name: 'DANNY TATE', kind: 'npc', note: 'Stable boy, 14' }, +]; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('cast-tray'); + document.body.appendChild(el); + }); + + it('shows an empty state with no cast', () => { + expect(el.querySelector('[data-role=empty]')).not.toBe(null); + }); + + it('renders a row per NPC with name and note', () => { + el.cast = CAST; + expect(el.querySelectorAll('[data-cast-id]').length).toBe(2); + const mary = el.querySelector('[data-cast-id="a"]'); + expect(mary.querySelector('.cast-name').textContent).toContain('MARY FLETCHER'); + expect(mary.querySelector('.cast-note').textContent).toContain('Innkeeper'); + }); + + it('keeps the secret hidden until revealed, then toggles it', () => { + el.cast = CAST; + const mary = () => el.querySelector('[data-cast-id="a"]'); + expect(mary().querySelector('[data-role=secret]')).toBe(null); + mary().querySelector('[data-role=reveal]').click(); + expect(el.querySelector('[data-cast-id="a"] [data-role=secret]').textContent).toContain('Crowe sew'); + // toggling again hides it + el.querySelector('[data-cast-id="a"] [data-role=reveal]').click(); + expect(el.querySelector('[data-cast-id="a"] [data-role=secret]')).toBe(null); + }); + + it('shows no reveal button for an NPC without a secret', () => { + el.cast = CAST; + expect(el.querySelector('[data-cast-id="b"] [data-role=reveal]')).toBe(null); + }); + + it('resets revealed secrets when the cast changes', () => { + el.cast = CAST; + el.querySelector('[data-cast-id="a"] [data-role=reveal]').click(); + el.cast = CAST; // reload + expect(el.querySelector('[data-cast-id="a"] [data-role=secret]')).toBe(null); + }); + + it('escapes author text', () => { + el.cast = [{ id: 'x', name: 'evil ', kind: 'npc', note: 'n" ', secret: 's" ' }]; + el.querySelector('[data-cast-id="x"] [data-role=reveal]').click(); + const row = el.querySelector('[data-cast-id="x"]'); + expect(row.innerHTML).not.toContain(''); + expect(row.innerHTML).not.toContain(''); + expect(row.innerHTML).not.toContain(''); + }); +}); diff --git a/tests/components/gm-shell-cast.test.js b/tests/components/gm-shell-cast.test.js new file mode 100644 index 0000000..d21996c --- /dev/null +++ b/tests/components/gm-shell-cast.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/gm-shell.js'; +import afterimage from '../../src/scenarios/afterimage.js'; + +describe(' cast tray', () => { + let el; + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + el = document.createElement('gm-shell'); + el.now = () => 0; + document.body.appendChild(el); + el.loadScenario(afterimage); + }); + + it('mounts a hidden cast-tray fed from the scenario cast', () => { + const tray = el.querySelector('cast-tray'); + expect(tray).not.toBe(null); + expect(tray.hidden).toBe(true); + expect(tray.cast.length).toBe(afterimage.cast.length); + }); + + it('open-tool cast reveals the cast-tray', () => { + el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'cast' }, bubbles: true })); + expect(el.querySelector('cast-tray').hidden).toBe(false); + }); + + it('opening the cast tray hides the other trays', () => { + el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'clues' }, bubbles: true })); + el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'cast' }, bubbles: true })); + expect(el.querySelector('clue-net').hidden).toBe(true); + expect(el.querySelector('cast-tray').hidden).toBe(false); + }); + + it('the rail exposes a cast chip that emits open-tool', () => { + const rail = el.querySelector('director-rail'); + let tool = null; + el.addEventListener('open-tool', (e) => { tool = e.detail.tool; }); + rail.querySelector('[data-role=open-cast]').click(); + expect(tool).toBe('cast'); + }); +});