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:
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>The Director</title>
|
<title>The Director</title>
|
||||||
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<gm-shell></gm-shell>
|
<gm-shell></gm-shell>
|
||||||
|
|||||||
@@ -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
@@ -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';
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['tests/**/*.test.js'],
|
include: ['tests/**/*.test.js'],
|
||||||
|
setupFiles: ['tests/setup.js'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user