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'); + }); +});