feat: <director-rail> pacing component
This commit is contained in:
@@ -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 = `<div class="rail rail--empty">No scenario loaded</div>`; 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 = `
|
||||
<div class="rail">
|
||||
<div class="cell"><span class="k">Session</span><span class="v" data-role="clock">${fmtElapsed(elapsedMs)}</span></div>
|
||||
<div class="cell here"><span class="k">You are here</span>
|
||||
<span class="v"><span data-role="here">${here}</span>${drift ? ` <span class="drift ${driftClass}">${drift}</span>` : ''}</span></div>
|
||||
<div class="cell trig"><span class="k">Next hard trigger</span><span class="v" data-role="next">${nhText}</span></div>
|
||||
<button class="reached" data-role="reached">✓ Reached it</button>
|
||||
<button class="tray-btn" data-role="open-dice" aria-label="Dice">🎲</button>
|
||||
</div>`;
|
||||
|
||||
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);
|
||||
@@ -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('<director-rail>', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user