From 6d529c9799681d1a31bf66cfb8eb652cc29cab29 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:15:14 +0100 Subject: [PATCH] feat: parking-lot note + wake-lock tray tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the last two design-Β§5 tray tools: - : one-tap capture of a thread to return to, stamped with the session time, persisted per-scenario (parkingLot store key), πŸ“ chip. - src/core/wake-lock.js: injectable Screen Wake Lock controller (keeps the tablet awake, re-acquires on foreground, graceful no-op where the API is absent). Toggled by a πŸ’‘ rail chip that lights when active. Tests: parking-lot.test.js, wake-lock.test.js, gm-shell-parking.test.js, gm-shell-wake.test.js. 179 tests pass; build clean. Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +- .../plans/2026-07-20-director-slice5.md | 8 +- src/components/director-rail.js | 10 ++- src/components/gm-shell.js | 34 ++++++++- src/components/parking-lot.js | 49 ++++++++++++ src/core/wake-lock.js | 39 ++++++++++ src/styles.css | 14 ++++ tests/components/gm-shell-parking.test.js | 58 ++++++++++++++ tests/components/gm-shell-wake.test.js | 52 +++++++++++++ tests/components/parking-lot.test.js | 59 +++++++++++++++ tests/core/wake-lock.test.js | 75 +++++++++++++++++++ 11 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 src/components/parking-lot.js create mode 100644 src/core/wake-lock.js create mode 100644 tests/components/gm-shell-parking.test.js create mode 100644 tests/components/gm-shell-wake.test.js create mode 100644 tests/components/parking-lot.test.js create mode 100644 tests/core/wake-lock.test.js diff --git a/README.md b/README.md index 304a3bb..3168bd3 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ Components are dumb renderers that emit events; the shell wires them together. | `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; β˜• 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` Β· `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/npc-tray.js` Β· `art-tray.js` Β· `prop-viewer.js` Β· `clue-net.js` Β· `cast-tray.js` Β· `break-timer.js` Β· `parking-lot.js` | The tray tools (`cast-tray` = roster w/ tap-to-reveal secrets; `break-timer` pauses the clock so breaks never poison drift; `parking-lot` = timestamped thread capture) | +| `src/core/wake-lock.js` | Screen Wake Lock controller (keep the tablet awake; re-acquires on foreground; graceful no-op where unsupported) | | `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 +98,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 (parking-lot note, wake-lock); online art "Generate"; markdown β†’ scenario-data generator; native iPad wrapper. +6. **Later** β€” online art "Generate"; markdown β†’ scenario-data generator; native iPad wrapper. (All design Β§5 tray tools β€” dice, NPC, art, clue-net, cast, break timer, parking-lot, wake-lock β€” are built.) ## Docs diff --git a/docs/superpowers/plans/2026-07-20-director-slice5.md b/docs/superpowers/plans/2026-07-20-director-slice5.md index a1acccb..bf6ed99 100644 --- a/docs/superpowers/plans/2026-07-20-director-slice5.md +++ b/docs/superpowers/plans/2026-07-20-director-slice5.md @@ -79,6 +79,12 @@ jsdom. No new dependencies. Fully offline. 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). +- **Parking-lot note + wake-lock built.** `` captures timestamped threads (session-time + stamp, persisted under `parkingLot`, πŸ“ chip). `src/core/wake-lock.js` is an injectable Screen Wake + Lock controller (re-acquires on foreground, graceful no-op where unsupported), toggled by a πŸ’‘ rail + chip that lights when active. Tests: `parking-lot.test.js`, `wake-lock.test.js`, `gm-shell-parking.test.js`, + `gm-shell-wake.test.js`. This completes every design Β§5 tray tool. + ## Still deferred (backlog) -- Parking-lot note + wake-lock tray tools; online art "Generate". +- Online art "Generate"; markdown β†’ scenario-data generator; native iPad wrapper. diff --git a/src/components/director-rail.js b/src/components/director-rail.js index 5dc4e64..a397863 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, paused } = this._state; + const { scenario, elapsedMs, stamps, paused, wakeActive } = 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 ?? 'β€”'); @@ -33,6 +33,8 @@ export class DirectorRail extends HTMLElement { + + `; this.querySelector('[data-role=reached]').addEventListener('click', () => { @@ -59,6 +61,12 @@ export class DirectorRail extends HTMLElement { this.querySelector('[data-role=open-break]').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'break' }, bubbles: true })); }); + this.querySelector('[data-role=open-parking]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'parking' }, bubbles: true })); + }); + this.querySelector('[data-role=toggle-wake]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('toggle-wake', { bubbles: true })); + }); } } customElements.define('director-rail', DirectorRail); diff --git a/src/components/gm-shell.js b/src/components/gm-shell.js index f27be9b..121b995 100644 --- a/src/components/gm-shell.js +++ b/src/components/gm-shell.js @@ -1,6 +1,7 @@ import { createStore } from '../core/store.js'; import { initialClock, start, pause, resume, elapsedMs, isRunning, isPaused } from '../core/clock.js'; import { nextUnreachedBeat } from '../core/timeline.js'; +import { createWakeLock } from '../core/wake-lock.js'; import './director-rail.js'; import './dice-tray.js'; import './npc-tray.js'; @@ -9,6 +10,7 @@ import './prop-viewer.js'; import './clue-net.js'; import './cast-tray.js'; import './break-timer.js'; +import './parking-lot.js'; import './con-hub.js'; import { getScenario } from '../scenarios/index.js'; @@ -24,6 +26,8 @@ export class GmShell extends HTMLElement { this.cast = []; this.revealedClues = []; this.breakEndsAt = null; + this.parkingLot = []; + this.wakeLock = createWakeLock(); this._tick = null; } @@ -38,6 +42,7 @@ export class GmShell extends HTMLElement { + `; this.addEventListener('reached', () => this.onReached()); @@ -47,6 +52,9 @@ export class GmShell extends HTMLElement { this.addEventListener('toggle-clue', (e) => this.onToggleClue(e)); this.addEventListener('start-break', (e) => this.onStartBreak(e)); this.addEventListener('end-break', () => this.onEndBreak()); + this.addEventListener('add-note', (e) => this.onAddNote(e)); + this.addEventListener('remove-note', (e) => this.onRemoveNote(e)); + this.addEventListener('toggle-wake', () => this.onToggleWake()); this.addEventListener('open-scenario', (e) => this.onOpenScenario(e)); this.addEventListener('open-hub', () => this.showHub()); } @@ -91,6 +99,8 @@ export class GmShell extends HTMLElement { net.revealed = this.revealedClues; this.querySelector('cast-tray').cast = this.scenario.cast || []; this.breakEndsAt = this.store.get('break', null); + this.parkingLot = this.store.get('parkingLot', []); + this.querySelector('parking-lot').notes = this.parkingLot; this.refresh(); } @@ -113,7 +123,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', break: 'break-timer' }; + const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net', cast: 'cast-tray', break: 'break-timer', parking: 'parking-lot' }; if (!(tool in trays)) return; const target = this.querySelector(trays[tool]); const willShow = target.hidden; @@ -159,6 +169,27 @@ export class GmShell extends HTMLElement { this.refresh(); } + onAddNote(e) { + const text = e.detail?.text?.trim(); + if (!text) return; + this.parkingLot = [...this.parkingLot, { at: this.elapsedMs(), text }]; + this.store.set('parkingLot', this.parkingLot); + this.querySelector('parking-lot').notes = this.parkingLot; + } + + onRemoveNote(e) { + const i = e.detail?.index; + if (i == null || i < 0 || i >= this.parkingLot.length) return; + this.parkingLot = this.parkingLot.filter((_, idx) => idx !== i); + this.store.set('parkingLot', this.parkingLot); + this.querySelector('parking-lot').notes = this.parkingLot; + } + + async onToggleWake() { + await this.wakeLock.toggle(); + this.refresh(); + } + refresh() { const paused = isPaused(this.clockState); this.querySelector('director-rail').update({ @@ -166,6 +197,7 @@ export class GmShell extends HTMLElement { elapsedMs: this.elapsedMs(), stamps: this.stamps, paused, + wakeActive: this.wakeLock.isActive(), }); this.querySelector('break-timer').state = { onBreak: paused, diff --git a/src/components/parking-lot.js b/src/components/parking-lot.js new file mode 100644 index 0000000..1eb9baf --- /dev/null +++ b/src/components/parking-lot.js @@ -0,0 +1,49 @@ +import { fmtElapsed } from '../core/format.js'; +import { escapeHtml } from '../core/escape-html.js'; + +// Parking-lot tray: one-tap capture of a thread to return to, stamped with the +// session time it was parked. Notes are fed in from the shell (persisted there); +// the component emits add-note / remove-note. +export class ParkingLot extends HTMLElement { + constructor() { + super(); + this._notes = []; + } + connectedCallback() { this.render(); } + + set notes(v) { this._notes = Array.isArray(v) ? v : []; this.render(); } + get notes() { return this._notes; } + + render() { + const list = this._notes.length + ? this._notes.map((n, i) => ` +
+ ${fmtElapsed(n.at || 0)} + ${escapeHtml(n.text)} + +
`).join('') + : `
No parked threads yet.
`; + + this.innerHTML = ` +
+
+ + +
+
${list}
+
`; + + const input = this.querySelector('[data-role=input]'); + const add = () => { + const text = input.value.trim(); + if (!text) return; + this.dispatchEvent(new CustomEvent('add-note', { detail: { text }, bubbles: true })); + }; + this.querySelector('[data-role=add]').addEventListener('click', add); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') add(); }); + this.querySelectorAll('[data-role=del]').forEach((b) => + b.addEventListener('click', () => + this.dispatchEvent(new CustomEvent('remove-note', { detail: { index: Number(b.dataset.index) }, bubbles: true })))); + } +} +customElements.define('parking-lot', ParkingLot); diff --git a/src/core/wake-lock.js b/src/core/wake-lock.js new file mode 100644 index 0000000..8c5d561 --- /dev/null +++ b/src/core/wake-lock.js @@ -0,0 +1,39 @@ +// Screen Wake Lock controller. Keeps the tablet awake while enabled and +// re-acquires the lock when the tab returns to the foreground (browsers drop it +// on hide). Degrades gracefully: on platforms without the API, `supported` is +// false and enable() is a no-op, so the toggle never makes a false promise. +// navigator/document are injectable for testing. +export function createWakeLock({ + navigator: nav = (typeof navigator !== 'undefined' ? navigator : undefined), + documentRef = (typeof document !== 'undefined' ? document : undefined), +} = {}) { + let sentinel = null; + let wanted = false; + const supported = !!(nav && nav.wakeLock && typeof nav.wakeLock.request === 'function'); + + async function acquire() { + if (!supported || !wanted || sentinel) return; + try { + sentinel = await nav.wakeLock.request('screen'); + sentinel.addEventListener?.('release', () => { sentinel = null; }); + } catch { sentinel = null; } + } + async function release() { + if (!sentinel) return; + try { await sentinel.release(); } catch { /* already gone */ } + sentinel = null; + } + if (documentRef?.addEventListener) { + documentRef.addEventListener('visibilitychange', () => { + if (wanted && documentRef.visibilityState === 'visible') acquire(); + }); + } + + return { + supported, + isActive: () => wanted, + async enable() { if (!supported) return; wanted = true; await acquire(); }, + async disable() { wanted = false; await release(); }, + async toggle() { if (wanted) await this.disable(); else await this.enable(); return wanted; }, + }; +} diff --git a/src/styles.css b/src/styles.css index 6268896..28a5ef2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -133,3 +133,17 @@ break-timer[hidden] { display: none; } .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; } + +/* Parking-lot tray + wake-lock chip */ +.parking-lot { position: absolute; top: 0; left: 0; right: 0; background: #0f141b; border-bottom: 1px solid #2b3644; padding: 14px; display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow-y: auto; } +parking-lot[hidden] { display: none; } +.pl-add { display: flex; gap: 8px; } +.pl-input { flex: 1; background: #161d27; border: 1px solid #2a3542; border-radius: 8px; padding: 10px 12px; color: #e7eef6; font-size: 13px; } +.pl-save { border: none; padding: 10px 18px; border-radius: 8px; background: #1c8a5a; color: #eafff4; font-weight: 700; font-size: 13px; } +.pl-list { display: flex; flex-direction: column; gap: 6px; } +.pl-note { display: flex; gap: 10px; align-items: center; padding: 8px 10px; border: 1px solid #263041; border-radius: 8px; } +.pl-at { font-size: 12px; font-weight: 700; color: #ffd479; font-variant-numeric: tabular-nums; flex: 0 0 auto; } +.pl-text { flex: 1; font-size: 13px; color: #e7eef6; } +.pl-del { flex: 0 0 auto; border: 1px solid #2a3542; background: #161d27; color: #8fa1b4; border-radius: 8px; width: 30px; height: 30px; font-size: 13px; } +.pl-empty { color: #6b7a8d; font-size: 12px; } +.tray-btn.on { background: #2a2013; color: #ffd479; box-shadow: inset 0 -2px 0 #ffd479; } diff --git a/tests/components/gm-shell-parking.test.js b/tests/components/gm-shell-parking.test.js new file mode 100644 index 0000000..82e0300 --- /dev/null +++ b/tests/components/gm-shell-parking.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/gm-shell.js'; +import afterimage from '../../src/scenarios/afterimage.js'; + +describe(' parking-lot', () => { + 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 parking-lot, revealed by open-tool parking', () => { + expect(el.querySelector('parking-lot').hidden).toBe(true); + el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'parking' }, bubbles: true })); + expect(el.querySelector('parking-lot').hidden).toBe(false); + }); + + it('parks a note stamped with the session time and persists it', () => { + el.startSession(); + t = 83 * 60000; // 1:23 into the session + el.dispatchEvent(new CustomEvent('add-note', { detail: { text: 'ledger thread' }, bubbles: true })); + expect(el.parkingLot).toEqual([{ at: 83 * 60000, text: 'ledger thread' }]); + const persisted = JSON.parse(localStorage.getItem('gmd.afterimage.parkingLot')); + expect(persisted).toEqual([{ at: 83 * 60000, text: 'ledger thread' }]); + expect(el.querySelector('parking-lot').notes.length).toBe(1); + }); + + it('removes a note by index and persists the change', () => { + el.dispatchEvent(new CustomEvent('add-note', { detail: { text: 'a' }, bubbles: true })); + el.dispatchEvent(new CustomEvent('add-note', { detail: { text: 'b' }, bubbles: true })); + el.dispatchEvent(new CustomEvent('remove-note', { detail: { index: 0 }, bubbles: true })); + expect(el.parkingLot.map((n) => n.text)).toEqual(['b']); + expect(JSON.parse(localStorage.getItem('gmd.afterimage.parkingLot')).map((n) => n.text)).toEqual(['b']); + }); + + it('reloads parked notes from the store', () => { + localStorage.setItem('gmd.afterimage.parkingLot', JSON.stringify([{ at: 0, text: 'kept' }])); + document.body.innerHTML = ''; + const el2 = document.createElement('gm-shell'); + el2.now = () => 0; + document.body.appendChild(el2); + el2.loadScenario(afterimage); + expect(el2.querySelector('parking-lot').notes.map((n) => n.text)).toEqual(['kept']); + }); + + it('the rail exposes a parking 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-parking]').click(); + expect(tool).toBe('parking'); + }); +}); diff --git a/tests/components/gm-shell-wake.test.js b/tests/components/gm-shell-wake.test.js new file mode 100644 index 0000000..f8d1bee --- /dev/null +++ b/tests/components/gm-shell-wake.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/gm-shell.js'; +import { createWakeLock } from '../../src/core/wake-lock.js'; +import afterimage from '../../src/scenarios/afterimage.js'; + +function mockNav() { + const requests = []; + return { + requests, + wakeLock: { request: async () => { const s = { release: async () => {}, addEventListener() {} }; requests.push(s); return s; } }, + }; +} + +describe(' wake-lock', () => { + let el, nav; + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + el = document.createElement('gm-shell'); + el.now = () => 0; + document.body.appendChild(el); + nav = mockNav(); + el.wakeLock = createWakeLock({ navigator: nav, documentRef: { visibilityState: 'visible', addEventListener() {} } }); + el.loadScenario(afterimage); + }); + + it('toggling the wake chip acquires the lock and lights the chip', async () => { + const rail = el.querySelector('director-rail'); + expect(rail.querySelector('[data-role=toggle-wake]').classList.contains('on')).toBe(false); + + await el.onToggleWake(); // await the full async toggle + refresh + + expect(nav.requests.length).toBe(1); + expect(el.wakeLock.isActive()).toBe(true); + expect(el.querySelector('director-rail [data-role=toggle-wake]').getAttribute('aria-pressed')).toBe('true'); + }); + + it('toggling again releases the lock and unlights the chip', async () => { + await el.onToggleWake(); + await el.onToggleWake(); + expect(el.wakeLock.isActive()).toBe(false); + expect(el.querySelector('director-rail [data-role=toggle-wake]').classList.contains('on')).toBe(false); + }); + + it('the rail exposes a wake chip that emits toggle-wake', () => { + const rail = el.querySelector('director-rail'); + let fired = 0; + el.addEventListener('toggle-wake', () => fired++); + rail.querySelector('[data-role=toggle-wake]').click(); + expect(fired).toBe(1); + }); +}); diff --git a/tests/components/parking-lot.test.js b/tests/components/parking-lot.test.js new file mode 100644 index 0000000..569e617 --- /dev/null +++ b/tests/components/parking-lot.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/parking-lot.js'; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('parking-lot'); + document.body.appendChild(el); + }); + + it('shows an empty state with no notes', () => { + expect(el.querySelector('[data-role=empty]')).not.toBe(null); + }); + + it('renders a note with its session timestamp', () => { + el.notes = [{ at: 83 * 60000, text: 'circle back to the ledger' }]; + const note = el.querySelector('.pl-note'); + expect(note.querySelector('.pl-at').textContent).toBe('1:23'); + expect(note.querySelector('.pl-text').textContent).toBe('circle back to the ledger'); + }); + + it('emits add-note with the trimmed text and clears on empty', () => { + let detail = null; + el.addEventListener('add-note', (e) => { detail = e.detail; }); + const input = el.querySelector('[data-role=input]'); + input.value = ' ask Priya about the editor '; + el.querySelector('[data-role=add]').click(); + expect(detail).toEqual({ text: 'ask Priya about the editor' }); + + // empty / whitespace does not emit + detail = null; + input.value = ' '; + el.querySelector('[data-role=add]').click(); + expect(detail).toBe(null); + }); + + it('adds on Enter', () => { + let fired = 0; + el.addEventListener('add-note', () => fired++); + const input = el.querySelector('[data-role=input]'); + input.value = 'x'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(fired).toBe(1); + }); + + it('emits remove-note with the note index', () => { + el.notes = [{ at: 0, text: 'a' }, { at: 0, text: 'b' }]; + let detail = null; + el.addEventListener('remove-note', (e) => { detail = e.detail; }); + el.querySelectorAll('[data-role=del]')[1].click(); + expect(detail).toEqual({ index: 1 }); + }); + + it('escapes author text', () => { + el.notes = [{ at: 0, text: 'evil tag' }]; + expect(el.querySelector('.pl-note').innerHTML).not.toContain(''); + }); +}); diff --git a/tests/core/wake-lock.test.js b/tests/core/wake-lock.test.js new file mode 100644 index 0000000..82b1d87 --- /dev/null +++ b/tests/core/wake-lock.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { createWakeLock } from '../../src/core/wake-lock.js'; + +function mockNav() { + const requests = []; + return { + requests, + wakeLock: { + request: async () => { + let onRelease = null; + const s = { + released: false, + release: async () => { s.released = true; onRelease?.(); }, + addEventListener: (ev, h) => { if (ev === 'release') onRelease = h; }, + }; + requests.push(s); + return s; + }, + }, + }; +} +const mockDoc = () => ({ visibilityState: 'visible', addEventListener() {} }); + +describe('createWakeLock', () => { + it('reports supported when the API is present', () => { + expect(createWakeLock({ navigator: mockNav(), documentRef: mockDoc() }).supported).toBe(true); + expect(createWakeLock({ navigator: {}, documentRef: mockDoc() }).supported).toBe(false); + }); + + it('acquires a lock on enable and releases it on disable', async () => { + const nav = mockNav(); + const wl = createWakeLock({ navigator: nav, documentRef: mockDoc() }); + await wl.enable(); + expect(wl.isActive()).toBe(true); + expect(nav.requests.length).toBe(1); + await wl.disable(); + expect(wl.isActive()).toBe(false); + expect(nav.requests[0].released).toBe(true); + }); + + it('toggles between states and returns the new state', async () => { + const wl = createWakeLock({ navigator: mockNav(), documentRef: mockDoc() }); + expect(await wl.toggle()).toBe(true); + expect(await wl.toggle()).toBe(false); + }); + + it('does not double-acquire while already held', async () => { + const nav = mockNav(); + const wl = createWakeLock({ navigator: nav, documentRef: mockDoc() }); + await wl.enable(); + await wl.enable(); + expect(nav.requests.length).toBe(1); + }); + + it('is a graceful no-op when unsupported', async () => { + const wl = createWakeLock({ navigator: {}, documentRef: mockDoc() }); + await wl.enable(); + expect(wl.isActive()).toBe(false); + expect(await wl.toggle()).toBe(false); + }); + + it('re-acquires when the tab becomes visible again after the lock dropped', async () => { + const nav = mockNav(); + let handler = null; + const doc = { visibilityState: 'visible', addEventListener: (_e, h) => { handler = h; } }; + const wl = createWakeLock({ navigator: nav, documentRef: doc }); + await wl.enable(); + expect(nav.requests.length).toBe(1); + await nav.requests[0].release(); // browser drops the lock on hide β†’ sentinel cleared + handler(); // visibilitychange back to visible + await Promise.resolve(); // let the async acquire settle + expect(nav.requests.length).toBe(2); + expect(wl.isActive()).toBe(true); + }); +});