feat: cast tray — surface scenario.cast rosters in a session

Add <cast-tray>: 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 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-07-20 22:34:38 +01:00
co-authored by Claude Opus 4.8
parent 4096291b1d
commit 9d0e6c7ec4
8 changed files with 181 additions and 4 deletions
+2 -2
View File
@@ -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` | `<director-rail>` — the always-on pacing bar (🎲 👤 ✏️ 🔍 chips) |
| `src/components/dice-tray.js` | `<dice-tray>` — 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` | `<gm-shell>` — 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
@@ -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.** `<cast-tray>` (`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 `<clue-net>`).
- 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".
+50
View File
@@ -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 = `<div class="cast-tray"><div class="cast-empty" data-role="empty">No cast configured for this scenario.</div></div>`;
return;
}
const rows = this._cast.map((c) => {
const revealed = this._revealed.has(c.id);
const hasSecret = !!c.secret;
return `
<div class="cast-row" data-cast-id="${escapeHtml(c.id)}">
<div class="cast-body">
<div class="cast-name">${escapeHtml(c.name)}${c.kind && c.kind !== 'npc' ? ` <span class="cast-kind">${escapeHtml(c.kind)}</span>` : ''}</div>
${c.note ? `<div class="cast-note">${escapeHtml(c.note)}</div>` : ''}
${hasSecret && revealed ? `<div class="cast-secret" data-role="secret">${escapeHtml(c.secret)}</div>` : ''}
</div>
${hasSecret ? `<button class="cast-reveal" data-role="reveal">${revealed ? 'Hide' : 'Secret'}</button>` : ''}
</div>`;
}).join('');
this.innerHTML = `<div class="cast-tray"><div class="cast-list">${rows}</div></div>`;
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);
+4
View File
@@ -31,6 +31,7 @@ export class DirectorRail extends HTMLElement {
<button class="tray-btn" data-role="open-npc" aria-label="NPC">👤</button>
<button class="tray-btn" data-role="open-art" aria-label="Art">✏️</button>
<button class="tray-btn" data-role="open-clues" aria-label="Clues">🔍</button>
<button class="tray-btn" data-role="open-cast" aria-label="Cast">👥</button>
</div>`;
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);
+4 -1
View File
@@ -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 {
<npc-tray hidden></npc-tray>
<art-tray hidden></art-tray>
<clue-net hidden></clue-net>
<cast-tray hidden></cast-tray>
</main>
<prop-viewer></prop-viewer>`;
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;
+13
View File
@@ -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; }
+60
View File
@@ -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('<cast-tray>', () => {
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 <b>', kind: 'npc', note: 'n" <i>', secret: 's" <u>' }];
el.querySelector('[data-cast-id="x"] [data-role=reveal]').click();
const row = el.querySelector('[data-cast-id="x"]');
expect(row.innerHTML).not.toContain('<b>');
expect(row.innerHTML).not.toContain('<i>');
expect(row.innerHTML).not.toContain('<u>');
});
});
+42
View File
@@ -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('<gm-shell> 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');
});
});