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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user