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
+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; }