feat: parking-lot note + wake-lock tray tools
Add the last two design-§5 tray tools: - <parking-lot>: 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
73a7ea72ad
commit
6d529c9799
@@ -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` | `<director-rail>` — the always-on pacing bar (🏠 🎲 👤 ✏️ 🔍 👥 ☕ chips; ☕ shows an on-break badge) |
|
||||
| `src/components/dice-tray.js` | `<dice-tray>` — 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` | `<gm-shell>` — 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
|
||||
|
||||
|
||||
@@ -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.** `<parking-lot>` 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.
|
||||
|
||||
@@ -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 = `<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 ?? '—');
|
||||
@@ -33,6 +33,8 @@ export class DirectorRail extends HTMLElement {
|
||||
<button class="tray-btn" data-role="open-clues" aria-label="Clues">🔍</button>
|
||||
<button class="tray-btn" data-role="open-cast" aria-label="Cast">👥</button>
|
||||
<button class="tray-btn" data-role="open-break" aria-label="Break timer">☕</button>
|
||||
<button class="tray-btn" data-role="open-parking" aria-label="Parking-lot notes">📝</button>
|
||||
<button class="tray-btn ${wakeActive ? 'on' : ''}" data-role="toggle-wake" aria-label="Keep screen awake" aria-pressed="${wakeActive ? 'true' : 'false'}">💡</button>
|
||||
</div>`;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
<clue-net hidden></clue-net>
|
||||
<cast-tray hidden></cast-tray>
|
||||
<break-timer hidden></break-timer>
|
||||
<parking-lot hidden></parking-lot>
|
||||
</main>
|
||||
<prop-viewer></prop-viewer>`;
|
||||
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,
|
||||
|
||||
@@ -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) => `
|
||||
<div class="pl-note" data-index="${i}">
|
||||
<span class="pl-at">${fmtElapsed(n.at || 0)}</span>
|
||||
<span class="pl-text">${escapeHtml(n.text)}</span>
|
||||
<button class="pl-del" data-role="del" data-index="${i}" aria-label="Resolve">✕</button>
|
||||
</div>`).join('')
|
||||
: `<div class="pl-empty" data-role="empty">No parked threads yet.</div>`;
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="parking-lot">
|
||||
<div class="pl-add">
|
||||
<input class="pl-input" data-role="input" placeholder="park a thread to return to…" />
|
||||
<button class="pl-save" data-role="add">Park</button>
|
||||
</div>
|
||||
<div class="pl-list">${list}</div>
|
||||
</div>`;
|
||||
|
||||
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);
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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('<gm-shell> 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');
|
||||
});
|
||||
});
|
||||
@@ -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('<gm-shell> 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import '../../src/components/parking-lot.js';
|
||||
|
||||
describe('<parking-lot>', () => {
|
||||
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 <b>tag</b>' }];
|
||||
expect(el.querySelector('.pl-note').innerHTML).not.toContain('<b>');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user