feat: wire NPC + art trays and prop viewer into the shell

This commit is contained in:
2026-07-20 15:51:58 +01:00
parent e648c17e79
commit 21d261cc2d
4 changed files with 118 additions and 5 deletions
+8
View File
@@ -27,6 +27,8 @@ export class DirectorRail extends HTMLElement {
<div class="cell trig"><span class="k">Next hard trigger</span><span class="v" data-role="next">${nhText}</span></div>
<button class="reached" data-role="reached">✓ Reached it</button>
<button class="tray-btn" data-role="open-dice" aria-label="Dice">🎲</button>
<button class="tray-btn" data-role="open-npc" aria-label="NPC">👤</button>
<button class="tray-btn" data-role="open-art" aria-label="Art">✏️</button>
</div>`;
this.querySelector('[data-role=reached]').addEventListener('click', () => {
@@ -35,6 +37,12 @@ export class DirectorRail extends HTMLElement {
this.querySelector('[data-role=open-dice]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true }));
});
this.querySelector('[data-role=open-npc]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'npc' }, bubbles: true }));
});
this.querySelector('[data-role=open-art]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'art' }, bubbles: true }));
});
}
}
customElements.define('director-rail', DirectorRail);
+29 -5
View File
@@ -3,6 +3,9 @@ import { initialClock, start, elapsedMs } from '../core/clock.js';
import { nextUnreachedBeat } from '../core/timeline.js';
import './director-rail.js';
import './dice-tray.js';
import './npc-tray.js';
import './art-tray.js';
import './prop-viewer.js';
export class GmShell extends HTMLElement {
constructor() {
@@ -12,15 +15,23 @@ export class GmShell extends HTMLElement {
this.store = null;
this.clockState = initialClock();
this.stamps = {};
this.cast = [];
this._tick = null;
}
connectedCallback() {
this.innerHTML = `
<director-rail></director-rail>
<main class="stage"><dice-tray hidden></dice-tray></main>`;
<main class="stage">
<dice-tray hidden></dice-tray>
<npc-tray hidden></npc-tray>
<art-tray hidden></art-tray>
</main>
<prop-viewer></prop-viewer>`;
this.addEventListener('reached', () => this.onReached());
this.addEventListener('open-tool', (e) => this.onOpenTool(e));
this.addEventListener('keep-npc', (e) => this.onKeepNpc(e));
this.addEventListener('show-prop', (e) => this.onShowProp(e));
}
loadScenario(scenario) {
@@ -28,6 +39,7 @@ export class GmShell extends HTMLElement {
this.store = createStore(scenario.meta.id);
this.clockState = this.store.get('clock', initialClock());
this.stamps = this.store.get('stamps', {});
this.cast = this.store.get('cast', []);
this.querySelector('dice-tray').systemId = scenario.meta.system;
this.refresh();
}
@@ -50,10 +62,22 @@ export class GmShell extends HTMLElement {
}
onOpenTool(e) {
if (e.detail?.tool === 'dice') {
const tray = this.querySelector('dice-tray');
tray.hidden = !tray.hidden;
}
const tool = e.detail?.tool;
const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray' };
if (!(tool in trays)) return;
const target = this.querySelector(trays[tool]);
const willShow = target.hidden;
for (const sel of Object.values(trays)) this.querySelector(sel).hidden = true;
target.hidden = !willShow;
}
onKeepNpc(e) {
this.cast = [...this.cast, e.detail];
this.store.set('cast', this.cast);
}
onShowProp(e) {
this.querySelector('prop-viewer').show(e.detail.src, e.detail.label);
}
refresh() {
+25
View File
@@ -30,3 +30,28 @@ gm-shell { display: block; }
.rolled:nth-child(2) { background: #b7c7d6; } .rolled:nth-child(3) { background: #e7c99a; }
.verdict { font-weight: 700; color: #ffd479; min-height: 20px; }
.roll { border: none; padding: 12px; border-radius: 9px; background: #1c8a5a; color: #eafff4; font-weight: 700; }
/* Slice 2 tray tools */
.npc-tray, .art-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; }
.npc-tray[hidden], .art-tray[hidden] { display: none; }
.pills { display: flex; gap: 6px; flex-wrap: wrap; }
.pill { font-size: 12px; padding: 5px 11px; border-radius: 20px; border: 1px solid #2b3644; background: #161d27; color: #9fb0c2; }
.pill.on { background: #1e3a52; border-color: #3f7fb0; color: #dcefff; }
.seed, .art-query { background: #161d27; border: 1px solid #2a3542; border-radius: 8px; padding: 9px 11px; color: #e7eef6; font-size: 13px; }
.generate { border: none; padding: 10px; border-radius: 9px; background: #1c8a5a; color: #eafff4; font-weight: 700; }
.npc-card { background: #12171f; border: 1px solid #263041; border-radius: 10px; padding: 12px; }
.npc-name { font-size: 15px; font-weight: 700; } .npc-seed { font-size: 11px; color: #8fa1b4; margin-bottom: 8px; }
.npc-card dl { margin: 0; display: grid; grid-template-columns: 64px 1fr; gap: 4px 10px; font-size: 12px; }
.npc-card dt { color: #6b7a8d; text-transform: uppercase; font-size: 9px; letter-spacing: .1em; padding-top: 2px; }
.npc-card dd { margin: 0; color: #d6e0ea; } .npc-card dd.secret { color: #ff9c9c; }
.npc-actions { display: flex; gap: 8px; margin-top: 10px; } .npc-btn { flex: 1; padding: 9px; border: none; border-radius: 8px; background: #212a35; color: #aebccb; font-weight: 700; }
.npc-btn.keep { background: #1c8a5a; color: #eafff4; }
.art-results { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.thumb { border: 1px solid #2a3542; border-radius: 8px; background: #141b24; padding: 0; overflow: hidden; position: relative; }
.thumb img { width: 100%; aspect-ratio: 3/4; object-fit: cover; display: block; }
.thumb-cap { position: absolute; bottom: 0; left: 0; right: 0; font-size: 9px; background: #0d1117cc; color: #cfe0f0; padding: 2px 4px; }
.art-empty { color: #6b7a8d; font-size: 12px; padding: 10px; }
.prop-overlay { position: fixed; inset: 0; background: #000000ee; display: flex; align-items: center; justify-content: center; z-index: 100; }
.prop-img { max-width: 96vw; max-height: 96vh; object-fit: contain; }
prop-viewer[hidden] { display: none; }
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/gm-shell.js';
import afterimage from '../../src/scenarios/afterimage.js';
describe('<gm-shell> slice 2 tools', () => {
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 the npc, art, and prop-viewer elements (trays hidden)', () => {
expect(el.querySelector('npc-tray').hidden).toBe(true);
expect(el.querySelector('art-tray').hidden).toBe(true);
expect(el.querySelector('prop-viewer')).not.toBe(null);
});
it('open-tool reveals one tray and hides the others', () => {
el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'npc' }, bubbles: true }));
expect(el.querySelector('npc-tray').hidden).toBe(false);
expect(el.querySelector('dice-tray').hidden).toBe(true);
el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'art' }, bubbles: true }));
expect(el.querySelector('art-tray').hidden).toBe(false);
expect(el.querySelector('npc-tray').hidden).toBe(true);
});
it('keep-npc appends to cast and persists under the scenario namespace', () => {
const npc = { name: 'Otto Brandt', genre: 'Noir', seed: 'foreman', look: 'x', manner: 'y', wants: 'z', secret: 's', voice: 'v' };
el.dispatchEvent(new CustomEvent('keep-npc', { detail: npc, bubbles: true }));
expect(el.cast).toHaveLength(1);
expect(el.cast[0].name).toBe('Otto Brandt');
const persisted = JSON.parse(localStorage.getItem('gmd.afterimage.cast'));
expect(persisted[0].name).toBe('Otto Brandt');
});
it('show-prop opens the prop-viewer with the given image', () => {
el.dispatchEvent(new CustomEvent('show-prop', { detail: { src: '/art/rain-pier.png', label: 'Pier' }, bubbles: true }));
const viewer = el.querySelector('prop-viewer');
expect(viewer.isOpen()).toBe(true);
expect(viewer.querySelector('[data-role=prop-img]').getAttribute('src')).toBe('/art/rain-pier.png');
});
it('the rail exposes npc and art chips that emit 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-npc]').click();
expect(tool).toBe('npc');
rail.querySelector('[data-role=open-art]').click();
expect(tool).toBe('art');
});
});