diff --git a/src/components/director-rail.js b/src/components/director-rail.js
index 94422d8..cb3bc5b 100644
--- a/src/components/director-rail.js
+++ b/src/components/director-rail.js
@@ -27,6 +27,8 @@ export class DirectorRail extends HTMLElement {
Next hard trigger${nhText}
+
+
`;
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);
diff --git a/src/components/gm-shell.js b/src/components/gm-shell.js
index b0eb3c4..baca761 100644
--- a/src/components/gm-shell.js
+++ b/src/components/gm-shell.js
@@ -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 = `
- `;
+
+
+
+
+
+ `;
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() {
diff --git a/src/styles.css b/src/styles.css
index 34a5e4b..a877c95 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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; }
diff --git a/tests/components/gm-shell-slice2.test.js b/tests/components/gm-shell-slice2.test.js
new file mode 100644
index 0000000..c7395a2
--- /dev/null
+++ b/tests/components/gm-shell-slice2.test.js
@@ -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(' 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');
+ });
+});