diff --git a/README.md b/README.md index 3bed62c..304a3bb 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,9 @@ Components are dumb renderers that emit events; the shell wires them together. | `src/components/con-hub.js` | `` โ€” the convention landing screen; deep-links into a scenario | | `src/scenarios/index.js` | Scenario registry (`getScenario(id)`) the hub deep-links through | | `src/con/` | Convention data (`continuum-2026.js`; `example-con.js` is a fully-timed template) | -| `src/components/director-rail.js` | `` โ€” the always-on pacing bar (๐ŸŽฒ ๐Ÿ‘ค โœ๏ธ ๐Ÿ” chips) | +| `src/components/director-rail.js` | `` โ€” the always-on pacing bar (๐Ÿ  ๐ŸŽฒ ๐Ÿ‘ค โœ๏ธ ๐Ÿ” ๐Ÿ‘ฅ โ˜• chips; โ˜• shows an on-break badge) | | `src/components/dice-tray.js` | `` โ€” pack selector + die picker + roll + verdict | -| `src/components/npc-tray.js` ยท `art-tray.js` ยท `prop-viewer.js` ยท `clue-net.js` ยท `cast-tray.js` | The tray tools (`cast-tray` renders `scenario.cast` with tap-to-reveal secrets) | +| `src/components/npc-tray.js` ยท `art-tray.js` ยท `prop-viewer.js` ยท `clue-net.js` ยท `cast-tray.js` ยท `break-timer.js` | The tray tools (`cast-tray` renders `scenario.cast` with tap-to-reveal secrets; `break-timer` pauses the session clock so breaks never poison drift) | | `src/components/gm-shell.js` | `` โ€” root wiring + persistence | | `src/scenarios/` | Scenario data files (six Continuum slots: `afterimage`, `day-one`, `vain-crown`, `silvery-moon`, `chopper`, `princes-bride`; `example-with-clues.js` is a template); `index.js` is the registry | | `public/` | `manifest.webmanifest`, `sw.js` (offline service worker) | @@ -97,7 +97,7 @@ compress if you reach a beat behind schedule. 3. **Slice 3** โ€” dice rule-packs (CoC d100, VANITY d6-pool, Panic & Glory, Dee Sanction) + tray pack selector. โœ… 4. **Slice 4** โ€” the clue **safety-net** (essential-clue gap tracker + fallbacks). โœ… 5. **Slice 5** โ€” the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). โœ… All **six** Continuum 2026 slots ported and scheduled with real times. -6. **Later** โ€” remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; markdown โ†’ scenario-data generator; native iPad wrapper. +6. **Later** โ€” remaining tray tools (parking-lot note, wake-lock); online art "Generate"; markdown โ†’ scenario-data generator; native iPad wrapper. ## Docs diff --git a/docs/superpowers/plans/2026-07-20-director-slice5.md b/docs/superpowers/plans/2026-07-20-director-slice5.md index 6e902cb..a1acccb 100644 --- a/docs/superpowers/plans/2026-07-20-director-slice5.md +++ b/docs/superpowers/plans/2026-07-20-director-slice5.md @@ -74,6 +74,11 @@ jsdom. No new dependencies. Fully offline. โ‰ค skill/20, special โ‰ค skill/5, success โ‰ค skill (bands transcribed from the Day One console). Day One's `meta.system` switched from `coc-d100` to `brp-d100`. `tests/dice/brp-d100.test.js` covers it. +- **Break timer built.** `` pauses the session clock for a break (reusing the clock's + existing `pause`/`resume`), so break time never poisons drift. Shell owns the pause/resume + + persistence (`break` store key); the rail shows a โ˜• on-break badge; `fmtCountdown` (M:SS) added to + `format.js`. Tests: `break-timer.test.js`, `gm-shell-break.test.js` (incl. the drift-honesty case). + ## Still deferred (backlog) -- Break-timer / parking-lot / wake-lock tray tools; online art "Generate". +- Parking-lot note + wake-lock tray tools; online art "Generate". diff --git a/src/components/break-timer.js b/src/components/break-timer.js new file mode 100644 index 0000000..5d60481 --- /dev/null +++ b/src/components/break-timer.js @@ -0,0 +1,52 @@ +import { fmtCountdown } from '../core/format.js'; + +// Break timer tray. Pauses the session clock for a meal/comfort break so the +// break time never poisons drift. The shell owns the clock; this component just +// picks a duration and emits start-break / end-break, and renders the countdown +// fed back to it via `state` ({ onBreak, remainingMs }). +const DURATIONS = [5, 10, 15, 20]; + +export class BreakTimer extends HTMLElement { + constructor() { + super(); + this._state = { onBreak: false, remainingMs: null }; + this._minutes = 10; + } + connectedCallback() { this.render(); } + + set state(v) { this._state = v || { onBreak: false, remainingMs: null }; this.render(); } + get state() { return this._state; } + + render() { + const { onBreak, remainingMs } = this._state; + + if (onBreak) { + const over = remainingMs != null && remainingMs < 0; + const clock = remainingMs != null ? fmtCountdown(Math.abs(remainingMs)) : ''; + const line = remainingMs == null + ? 'On break โ€” clock paused' + : over ? `Over by ${clock} โ€” resume when ready` : `Back in ${clock}`; + this.innerHTML = ` +
+
${line}
+ +
`; + this.querySelector('[data-role=end]').addEventListener('click', () => + this.dispatchEvent(new CustomEvent('end-break', { bubbles: true }))); + return; + } + + const pills = DURATIONS.map((m) => + ``).join(''); + this.innerHTML = ` +
+
${pills}
+ +
`; + this.querySelectorAll('[data-min]').forEach((b) => + b.addEventListener('click', () => { this._minutes = Number(b.dataset.min); this.render(); })); + this.querySelector('[data-role=start]').addEventListener('click', () => + this.dispatchEvent(new CustomEvent('start-break', { detail: { minutes: this._minutes }, bubbles: true }))); + } +} +customElements.define('break-timer', BreakTimer); diff --git a/src/components/director-rail.js b/src/components/director-rail.js index 627debf..5dc4e64 100644 --- a/src/components/director-rail.js +++ b/src/components/director-rail.js @@ -10,7 +10,7 @@ export class DirectorRail extends HTMLElement { update(patch) { Object.assign(this._state, patch); this.render(); } render() { - const { scenario, elapsedMs, stamps } = this._state; + const { scenario, elapsedMs, stamps, paused } = 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 ?? 'โ€”'); @@ -21,7 +21,7 @@ export class DirectorRail extends HTMLElement { this.innerHTML = `
-
Session${fmtElapsed(elapsedMs)}
+
Session${fmtElapsed(elapsedMs)}${paused ? ' โ˜• BREAK' : ''}
You are here ${here}${drift ? ` ${drift}` : ''}
Next hard trigger${nhText}
@@ -32,6 +32,7 @@ export class DirectorRail extends HTMLElement { +
`; this.querySelector('[data-role=reached]').addEventListener('click', () => { @@ -55,6 +56,9 @@ export class DirectorRail extends HTMLElement { this.querySelector('[data-role=open-cast]').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'cast' }, bubbles: true })); }); + this.querySelector('[data-role=open-break]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'break' }, bubbles: true })); + }); } } customElements.define('director-rail', DirectorRail); diff --git a/src/components/gm-shell.js b/src/components/gm-shell.js index 9f5b3d1..f27be9b 100644 --- a/src/components/gm-shell.js +++ b/src/components/gm-shell.js @@ -1,5 +1,5 @@ import { createStore } from '../core/store.js'; -import { initialClock, start, elapsedMs } from '../core/clock.js'; +import { initialClock, start, pause, resume, elapsedMs, isRunning, isPaused } from '../core/clock.js'; import { nextUnreachedBeat } from '../core/timeline.js'; import './director-rail.js'; import './dice-tray.js'; @@ -8,6 +8,7 @@ import './art-tray.js'; import './prop-viewer.js'; import './clue-net.js'; import './cast-tray.js'; +import './break-timer.js'; import './con-hub.js'; import { getScenario } from '../scenarios/index.js'; @@ -22,6 +23,7 @@ export class GmShell extends HTMLElement { this.stamps = {}; this.cast = []; this.revealedClues = []; + this.breakEndsAt = null; this._tick = null; } @@ -35,6 +37,7 @@ export class GmShell extends HTMLElement { + `; this.addEventListener('reached', () => this.onReached()); @@ -42,6 +45,8 @@ export class GmShell extends HTMLElement { this.addEventListener('keep-npc', (e) => this.onKeepNpc(e)); this.addEventListener('show-prop', (e) => this.onShowProp(e)); this.addEventListener('toggle-clue', (e) => this.onToggleClue(e)); + this.addEventListener('start-break', (e) => this.onStartBreak(e)); + this.addEventListener('end-break', () => this.onEndBreak()); this.addEventListener('open-scenario', (e) => this.onOpenScenario(e)); this.addEventListener('open-hub', () => this.showHub()); } @@ -85,6 +90,7 @@ export class GmShell extends HTMLElement { net.clues = this.scenario.clues || []; net.revealed = this.revealedClues; this.querySelector('cast-tray').cast = this.scenario.cast || []; + this.breakEndsAt = this.store.get('break', null); this.refresh(); } @@ -107,7 +113,7 @@ export class GmShell extends HTMLElement { onOpenTool(e) { const tool = e.detail?.tool; - const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net', cast: 'cast-tray' }; + const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net', cast: 'cast-tray', break: 'break-timer' }; if (!(tool in trays)) return; const target = this.querySelector(trays[tool]); const willShow = target.hidden; @@ -133,12 +139,38 @@ export class GmShell extends HTMLElement { this.querySelector('prop-viewer').show(e.detail.src, e.detail.label); } + onStartBreak(e) { + if (!isRunning(this.clockState)) return; // a break only makes sense mid-session + const now = this.now(); + this.clockState = pause(this.clockState, now); + this.breakEndsAt = now + (e.detail?.minutes ?? 10) * 60000; + this.store.set('clock', this.clockState); + this.store.set('break', this.breakEndsAt); + if (!this._tick) this._tick = setInterval(() => this.refresh(), 1000); + this.refresh(); + } + + onEndBreak() { + if (!isPaused(this.clockState)) return; + this.clockState = resume(this.clockState, this.now()); + this.breakEndsAt = null; + this.store.set('clock', this.clockState); + this.store.set('break', null); + this.refresh(); + } + refresh() { + const paused = isPaused(this.clockState); this.querySelector('director-rail').update({ scenario: this.scenario, elapsedMs: this.elapsedMs(), stamps: this.stamps, + paused, }); + this.querySelector('break-timer').state = { + onBreak: paused, + remainingMs: paused && this.breakEndsAt != null ? this.breakEndsAt - this.now() : null, + }; } } customElements.define('gm-shell', GmShell); diff --git a/src/core/format.js b/src/core/format.js index ac938c0..6138e7a 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -5,6 +5,13 @@ export function fmtElapsed(ms) { return `${h}:${String(m).padStart(2, '0')}`; } +export function fmtCountdown(ms) { + const total = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + export function fmtDrift(driftMin) { if (driftMin == null) return ''; const r = Math.round(driftMin); diff --git a/src/styles.css b/src/styles.css index 7fa5596..6268896 100644 --- a/src/styles.css +++ b/src/styles.css @@ -120,3 +120,16 @@ cast-tray[hidden] { display: none; } .cast-secret { font-size: 12px; color: #ffd479; margin-top: 6px; padding-top: 6px; border-top: 1px dashed #2b3644; } .cast-reveal { flex: 0 0 auto; border: 1px solid #2a3542; background: #161d27; color: #cfe0f0; font-size: 11px; font-weight: 700; border-radius: 8px; padding: 6px 12px; } .cast-empty { color: #6b7a8d; font-size: 12px; } + +/* Break timer tray + rail break badge */ +.break-timer { position: absolute; top: 0; left: 0; right: 0; background: #0f141b; border-bottom: 1px solid #2b3644; padding: 14px; display: flex; flex-direction: column; gap: 12px; } +break-timer[hidden] { display: none; } +.break-durs { display: flex; gap: 8px; flex-wrap: wrap; } +.break-dur { min-width: 54px; padding: 9px 14px; border-radius: 9px; background: #161d27; border: 1px solid #2a3542; color: #c6d5e6; font-weight: 600; font-size: 14px; } +.break-dur.on { background: #2a2013; border-color: #7a651f; color: #ffd479; } +.break-start { border: none; padding: 12px; border-radius: 9px; background: #2a2013; color: #ffd479; font-weight: 700; font-size: 14px; border: 1px solid #7a651f; } +.break-status { font-size: 20px; font-weight: 800; color: #ffd479; padding: 6px 2px; } +.break-status.over { color: #ff9c9c; } +.break-end { border: none; padding: 12px; border-radius: 9px; background: #1c8a5a; color: #eafff4; font-weight: 700; font-size: 14px; } +.break-badge { margin-left: 8px; font-size: 10px; font-weight: 800; letter-spacing: .1em; color: #ffd479; border: 1px solid #7a651f; border-radius: 12px; padding: 1px 7px; vertical-align: middle; } +.rail .cell.on-break .v { color: #ffd479; } diff --git a/tests/components/break-timer.test.js b/tests/components/break-timer.test.js new file mode 100644 index 0000000..5d8500b --- /dev/null +++ b/tests/components/break-timer.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/break-timer.js'; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('break-timer'); + document.body.appendChild(el); + }); + + it('offers duration pills and a start button when not on break', () => { + expect(el.querySelector('[data-role=durs]')).not.toBe(null); + expect(el.querySelector('[data-role=start]')).not.toBe(null); + expect(el.querySelector('[data-role=end]')).toBe(null); + }); + + it('emits start-break with the selected duration', () => { + let detail = null; + el.addEventListener('start-break', (e) => { detail = e.detail; }); + el.querySelector('[data-min="15"]').click(); // select 15m + el.querySelector('[data-role=start]').click(); + expect(detail).toEqual({ minutes: 15 }); + }); + + it('defaults to a 10-minute break', () => { + let detail = null; + el.addEventListener('start-break', (e) => { detail = e.detail; }); + el.querySelector('[data-role=start]').click(); + expect(detail).toEqual({ minutes: 10 }); + }); + + it('shows the countdown and an end button while on break', () => { + el.state = { onBreak: true, remainingMs: 7 * 60000 + 5 * 1000 }; + expect(el.querySelector('[data-role=durs]')).toBe(null); + expect(el.querySelector('[data-role=status]').textContent).toBe('Back in 7:05'); + expect(el.querySelector('[data-role=end]')).not.toBe(null); + }); + + it('shows an overrun once the break runs over', () => { + el.state = { onBreak: true, remainingMs: -90 * 1000 }; + const status = el.querySelector('[data-role=status]'); + expect(status.classList.contains('over')).toBe(true); + expect(status.textContent).toContain('Over by 1:30'); + }); + + it('emits end-break when ending', () => { + el.state = { onBreak: true, remainingMs: 60000 }; + let fired = 0; + el.addEventListener('end-break', () => fired++); + el.querySelector('[data-role=end]').click(); + expect(fired).toBe(1); + }); +}); diff --git a/tests/components/gm-shell-break.test.js b/tests/components/gm-shell-break.test.js new file mode 100644 index 0000000..9f086b4 --- /dev/null +++ b/tests/components/gm-shell-break.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/gm-shell.js'; +import afterimage from '../../src/scenarios/afterimage.js'; + +describe(' break timer', () => { + 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 hidden break-timer, revealed by open-tool break', () => { + expect(el.querySelector('break-timer').hidden).toBe(true); + el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'break' }, bubbles: true })); + expect(el.querySelector('break-timer').hidden).toBe(false); + }); + + it('a break pauses the clock so break time never poisons drift', () => { + el.startSession(); // t=0 + t = 30 * 60000; // 30 min of play + expect(Math.round(el.elapsedMs() / 60000)).toBe(30); + + el.dispatchEvent(new CustomEvent('start-break', { detail: { minutes: 10 }, bubbles: true })); + expect(el.querySelector('director-rail [data-role=break-badge]')).not.toBe(null); + expect(el.querySelector('break-timer').state.onBreak).toBe(true); + + t = 50 * 60000; // 20 min of wall-clock break + expect(Math.round(el.elapsedMs() / 60000)).toBe(30); // frozen while on break + + el.dispatchEvent(new CustomEvent('end-break', { bubbles: true })); + expect(el.querySelector('director-rail [data-role=break-badge]')).toBe(null); + + t = 60 * 60000; // 10 more min of play after the break + expect(Math.round(el.elapsedMs() / 60000)).toBe(40); // 40 min play, break excluded + }); + + it('persists the break so the paused clock survives a reload', () => { + el.startSession(); + t = 10 * 60000; + el.dispatchEvent(new CustomEvent('start-break', { detail: { minutes: 5 }, bubbles: true })); + expect(JSON.parse(localStorage.getItem('gmd.afterimage.break'))).toBe(el.breakEndsAt); + + // simulate reload + document.body.innerHTML = ''; + const el2 = document.createElement('gm-shell'); + el2.now = () => t; + document.body.appendChild(el2); + el2.loadScenario(afterimage); + expect(el2.querySelector('break-timer').state.onBreak).toBe(true); + expect(el2.querySelector('director-rail [data-role=break-badge]')).not.toBe(null); + }); + + it('ignores a start-break before the session has started', () => { + el.dispatchEvent(new CustomEvent('start-break', { detail: { minutes: 10 }, bubbles: true })); + expect(el.querySelector('break-timer').state.onBreak).toBe(false); + expect(el.breakEndsAt).toBe(null); + }); + + it('the rail exposes a break chip that emits open-tool', () => { + const rail = el.querySelector('director-rail'); + let tool = null; + el.addEventListener('open-tool', (e) => { tool = e.detail.tool; }); + rail.querySelector('[data-role=open-break]').click(); + expect(tool).toBe('break'); + }); +}); diff --git a/tests/core/format.test.js b/tests/core/format.test.js index bed5192..f2de7b2 100644 --- a/tests/core/format.test.js +++ b/tests/core/format.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { fmtElapsed, fmtDrift } from '../../src/core/format.js'; +import { fmtElapsed, fmtDrift, fmtCountdown } from '../../src/core/format.js'; describe('formatting', () => { it('formats elapsed time', () => { @@ -13,4 +13,10 @@ describe('formatting', () => { expect(fmtDrift(6)).toBe('+6 behind'); expect(fmtDrift(-3)).toBe('3 ahead'); }); + it('formats a countdown as M:SS', () => { + expect(fmtCountdown(0)).toBe('0:00'); + expect(fmtCountdown(9 * 60000 + 47 * 1000)).toBe('9:47'); + expect(fmtCountdown(10 * 60000)).toBe('10:00'); + expect(fmtCountdown(-5000)).toBe('0:00'); // clamps negatives + }); });