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