diff --git a/src/components/director-rail.js b/src/components/director-rail.js
new file mode 100644
index 0000000..94422d8
--- /dev/null
+++ b/src/components/director-rail.js
@@ -0,0 +1,40 @@
+import { analyze } from '../core/timeline.js';
+import { fmtElapsed, fmtDrift } from '../core/format.js';
+
+export class DirectorRail extends HTMLElement {
+ constructor() {
+ super();
+ this._state = { scenario: null, elapsedMs: 0, stamps: {} };
+ }
+ connectedCallback() { this.render(); }
+ update(patch) { Object.assign(this._state, patch); this.render(); }
+
+ render() {
+ const { scenario, elapsedMs, stamps } = this._state;
+ if (!scenario) { this.innerHTML = `
No scenario loaded
`; return; }
+ const m = analyze(scenario.timeline, elapsedMs / 60000, stamps);
+ const here = m.currentBeat ? m.currentBeat.label : (scenario.timeline[0]?.label ?? 'โ');
+ const drift = fmtDrift(m.driftMin);
+ const driftClass = m.driftMin > 0 ? 'behind' : 'ahead';
+ const nh = m.nextHardTrigger;
+ const nhText = nh ? `${nh.label} ยท ~${Math.max(0, Math.round(m.minutesToNextHard))}m` : 'โ';
+
+ this.innerHTML = `
+
+
Session${fmtElapsed(elapsedMs)}
+
You are here
+ ${here}${drift ? ` ${drift}` : ''}
+
Next hard trigger${nhText}
+
+
+
`;
+
+ this.querySelector('[data-role=reached]').addEventListener('click', () => {
+ this.dispatchEvent(new CustomEvent('reached', { bubbles: true }));
+ });
+ this.querySelector('[data-role=open-dice]').addEventListener('click', () => {
+ this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true }));
+ });
+ }
+}
+customElements.define('director-rail', DirectorRail);
diff --git a/tests/components/director-rail.test.js b/tests/components/director-rail.test.js
new file mode 100644
index 0000000..cdca223
--- /dev/null
+++ b/tests/components/director-rail.test.js
@@ -0,0 +1,34 @@
+// tests/components/director-rail.test.js
+import { describe, it, expect, beforeEach } from 'vitest';
+import '../../src/components/director-rail.js';
+import afterimage from '../../src/scenarios/afterimage.js';
+
+describe('', () => {
+ let el;
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ el = document.createElement('director-rail');
+ document.body.appendChild(el);
+ });
+
+ it('shows the first beat and no drift before any stamp', () => {
+ el.update({ scenario: afterimage, elapsedMs: 0, stamps: {} });
+ expect(el.querySelector('[data-role=here]').textContent).toContain('The pier');
+ expect(el.querySelector('.drift')).toBe(null);
+ expect(el.querySelector('[data-role=clock]').textContent).toBe('0:00');
+ });
+
+ it('shows drift and the next hard trigger after a late stamp', () => {
+ el.update({ scenario: afterimage, elapsedMs: 40 * 60000, stamps: { 'a1-open': 6 } });
+ expect(el.querySelector('.drift').textContent).toBe('+6 behind');
+ expect(el.querySelector('[data-role=next]').textContent).toContain('Door-cam');
+ });
+
+ it('emits "reached" when the button is clicked', () => {
+ el.update({ scenario: afterimage, elapsedMs: 0, stamps: {} });
+ let fired = 0;
+ el.addEventListener('reached', () => fired++);
+ el.querySelector('[data-role=reached]').click();
+ expect(fired).toBe(1);
+ });
+});