feat: <gm-shell> 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.
This commit is contained in:
2026-07-20 14:15:50 +01:00
parent 549cb190d2
commit 1348048d50
7 changed files with 164 additions and 1 deletions
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>The Director</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<gm-shell></gm-shell>
+67
View File
@@ -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 = `
<director-rail></director-rail>
<main class="stage"><dice-tray hidden></dice-tray></main>`;
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);
+11 -1
View File
@@ -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);
});
}
+32
View File
@@ -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; }
+38
View File
@@ -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('<gm-shell>', () => {
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);
});
});
+14
View File
@@ -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,
});
}
+1
View File
@@ -4,5 +4,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/**/*.test.js'],
setupFiles: ['tests/setup.js'],
},
});