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:
co-authored by
Claude Opus 4.8
parent
4096291b1d
commit
9d0e6c7ec4
@@ -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".
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user