From 1348048d508e575641e179286ba7f0e9af173eed Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Mon, 20 Jul 2026 14:15:50 +0100 Subject: [PATCH] feat: wiring, styles, and AFTERIMAGE boot Adds the gm-shell root element that owns store/clock/stamps/scenario, mounts director-rail and dice-tray, handles reached (stamp + persist) and open-tool (toggle dice tray), and boots the AFTERIMAGE scenario in main.js. Also adds tests/setup.js + a vite.config.js setupFiles entry: Node's newer built-in global `localStorage` shadows jsdom's per-window implementation in vitest's jsdom environment, leaving localStorage undefined in tests until replaced with a real jsdom-backed instance. --- index.html | 1 + src/components/gm-shell.js | 67 +++++++++++++++++++++++++++++++ src/main.js | 12 +++++- src/styles.css | 32 +++++++++++++++ tests/components/gm-shell.test.js | 38 ++++++++++++++++++ tests/setup.js | 14 +++++++ vite.config.js | 1 + 7 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/components/gm-shell.js create mode 100644 src/styles.css create mode 100644 tests/components/gm-shell.test.js create mode 100644 tests/setup.js diff --git a/index.html b/index.html index 4c307ad..8c896fc 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ The Director + diff --git a/src/components/gm-shell.js b/src/components/gm-shell.js new file mode 100644 index 0000000..b0eb3c4 --- /dev/null +++ b/src/components/gm-shell.js @@ -0,0 +1,67 @@ +import { createStore } from '../core/store.js'; +import { initialClock, start, elapsedMs } from '../core/clock.js'; +import { nextUnreachedBeat } from '../core/timeline.js'; +import './director-rail.js'; +import './dice-tray.js'; + +export class GmShell extends HTMLElement { + constructor() { + super(); + this.now = () => Date.now(); + this.scenario = null; + this.store = null; + this.clockState = initialClock(); + this.stamps = {}; + this._tick = null; + } + + connectedCallback() { + this.innerHTML = ` + +
`; + this.addEventListener('reached', () => this.onReached()); + this.addEventListener('open-tool', (e) => this.onOpenTool(e)); + } + + loadScenario(scenario) { + this.scenario = scenario; + this.store = createStore(scenario.meta.id); + this.clockState = this.store.get('clock', initialClock()); + this.stamps = this.store.get('stamps', {}); + this.querySelector('dice-tray').systemId = scenario.meta.system; + this.refresh(); + } + + startSession() { + this.clockState = start(this.clockState, this.now()); + this.store.set('clock', this.clockState); + if (!this._tick) this._tick = setInterval(() => this.refresh(), 1000); + this.refresh(); + } + + elapsedMs() { return elapsedMs(this.clockState, this.now()); } + + onReached() { + const beat = nextUnreachedBeat(this.scenario.timeline, this.stamps); + if (!beat) return; + this.stamps = { ...this.stamps, [beat.id]: this.elapsedMs() / 60000 }; + this.store.set('stamps', this.stamps); + this.refresh(); + } + + onOpenTool(e) { + if (e.detail?.tool === 'dice') { + const tray = this.querySelector('dice-tray'); + tray.hidden = !tray.hidden; + } + } + + refresh() { + this.querySelector('director-rail').update({ + scenario: this.scenario, + elapsedMs: this.elapsedMs(), + stamps: this.stamps, + }); + } +} +customElements.define('gm-shell', GmShell); diff --git a/src/main.js b/src/main.js index f8b25d9..4c9e265 100644 --- a/src/main.js +++ b/src/main.js @@ -1,2 +1,12 @@ -// Boot entry. Component registration is added in later tasks. +// Boot entry. +import './components/gm-shell.js'; +import afterimage from './scenarios/afterimage.js'; + export const APP = 'the-director'; + +if (typeof document !== 'undefined' && document.querySelector('gm-shell')) { + customElements.whenDefined('gm-shell').then(() => { + const shell = document.querySelector('gm-shell'); + shell.loadScenario(afterimage); + }); +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..34a5e4b --- /dev/null +++ b/src/styles.css @@ -0,0 +1,32 @@ +:root { color-scheme: dark; } +* { box-sizing: border-box; } +body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b0e13; color: #e7eef6; } +gm-shell { display: block; } + +.rail { display: flex; align-items: stretch; height: 64px; background: linear-gradient(180deg,#12171f,#0d1117); + border-bottom: 1px solid #263041; } +.rail .cell { padding: 7px 14px; display: flex; flex-direction: column; justify-content: center; border-right: 1px solid #1e2733; } +.rail .here { flex: 1; } +.rail .k { font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: #6b7a8d; } +.rail .v { font-size: 15px; font-weight: 600; } +.rail .trig .v { color: #ffd479; } +.drift { margin-left: 6px; padding: 1px 7px; border-radius: 20px; font-size: 11px; font-weight: 700; } +.drift.behind { background: #4a1f1f; color: #ff9c9c; } +.drift.ahead { background: #1f3a2a; color: #9cffc4; } +.reached { border: none; padding: 0 18px; background: #1c8a5a; color: #eafff4; font-weight: 700; font-size: 14px; } +.tray-btn { border: none; width: 60px; background: #182130; color: #cfe0f0; font-size: 20px; } + +.stage { position: relative; min-height: calc(100vh - 64px); } +.dice-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; } +.dice-tray[hidden] { display: none; } +.picker { display: flex; gap: 8px; flex-wrap: wrap; } +.die { width: 54px; height: 44px; border-radius: 9px; background: #161d27; border: 1px solid #2a3542; color: #c6d5e6; font-weight: 600; } +.die.sel { background: #17251c; border-color: #3aa06a; color: #c9f7dc; } +.felt { min-height: 90px; border-radius: 10px; background: radial-gradient(circle at 50% 40%,#16241c,#0e1712); + border: 1px solid #24352a; display: flex; align-items: center; justify-content: center; gap: 18px; } +.rolled { width: 52px; height: 52px; border-radius: 10px; background: #e9e1d2; color: #2c2618; + display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 800; } +.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; } diff --git a/tests/components/gm-shell.test.js b/tests/components/gm-shell.test.js new file mode 100644 index 0000000..8629fec --- /dev/null +++ b/tests/components/gm-shell.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/gm-shell.js'; +import afterimage from '../../src/scenarios/afterimage.js'; + +describe('', () => { + let el, t; + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + t = 0; + el = document.createElement('gm-shell'); + el.now = () => t; + document.body.appendChild(el); + el.loadScenario(afterimage); + }); + + it('mounts a director-rail for the loaded scenario', () => { + expect(el.querySelector('director-rail')).not.toBe(null); + expect(el.querySelector('director-rail [data-role=here]').textContent).toContain('The pier'); + }); + + it('stamps the next beat on "reached" and persists it', () => { + el.startSession(); // start at t=0 + t = 6 * 60000; // 6 minutes later + el.querySelector('[data-role=reached]').dispatchEvent(new CustomEvent('reached', { bubbles: true })); + expect(el.stamps['a1-open']).toBeCloseTo(6, 5); + const persisted = JSON.parse(localStorage.getItem('gmd.afterimage.stamps')); + expect(persisted['a1-open']).toBeCloseTo(6, 5); + // rail now shows the reached beat as current + expect(el.querySelector('director-rail [data-role=here]').textContent).toContain('The pier'); + }); + + it('reveals the dice tray on open-tool', () => { + expect(el.querySelector('dice-tray').hidden).toBe(true); + el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true })); + expect(el.querySelector('dice-tray').hidden).toBe(false); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..86c3d5b --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,14 @@ +// Workaround: Node's own experimental global `localStorage` (added in recent +// Node versions) shadows jsdom's per-window localStorage before vitest's +// jsdom environment can install it, leaving `localStorage`/`window.localStorage` +// undefined in tests. Replace it with a real jsdom-backed Storage instance. +import { JSDOM } from 'jsdom'; + +if (typeof localStorage === 'undefined' || typeof localStorage.setItem !== 'function') { + const { window } = new JSDOM('', { url: 'http://localhost/' }); + Object.defineProperty(globalThis, 'localStorage', { + value: window.localStorage, + configurable: true, + writable: true, + }); +} diff --git a/vite.config.js b/vite.config.js index 274012c..5a822a9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,5 +4,6 @@ export default defineConfig({ test: { environment: 'jsdom', include: ['tests/**/*.test.js'], + setupFiles: ['tests/setup.js'], }, });