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:
slaguru666
2026-07-20 23:15:14 +01:00
co-authored by Claude Opus 4.8
parent 73a7ea72ad
commit 6d529c9799
11 changed files with 398 additions and 5 deletions
+3 -2
View File
@@ -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.
+9 -1
View File
@@ -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);
+33 -1
View File
@@ -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,
+49
View File
@@ -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);
+39
View File
@@ -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; },
};
}
+14
View File
@@ -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; }
+58
View File
@@ -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');
});
});
+52
View File
@@ -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);
});
});
+59
View File
@@ -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>');
});
});
+75
View File
@@ -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);
});
});