fix: iPad layout — clamp rail labels, fill the stage with a run-board

The rail was a fixed 64px bar cramming 3 info cells against 9 chips, so
long beat/trigger labels wrapped and clipped ('bled'), and trays were thin
top strips leaving the iPad screen mostly empty.

- Rail: min-height + 2-line clamp on the beat/trigger labels (no bleed),
  nowrap 'Reached it', slimmer chips, tabular clock.
- Trays now fill the stage as solid panels (inset:0) instead of a strip.
- New <run-board>: the whole run at a glance behind the trays — every beat
  with target time, done beats stamped/dimmed, next beat highlighted, hard
  triggers + drift. Fills the previously-empty stage with the most useful
  GM view.
- @media (min-width:1024px/1366px): larger type, roomier rows, more art
  columns — uses the tablet space.

tests/components/run-board.test.js. 219 tests pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-07-21 11:41:03 +01:00
co-authored by Claude Opus 4.8
parent 1d6e1f4b62
commit 0ec09290ea
4 changed files with 191 additions and 9 deletions
+7
View File
@@ -11,6 +11,7 @@ import './clue-net.js';
import './cast-tray.js';
import './break-timer.js';
import './parking-lot.js';
import './run-board.js';
import './con-hub.js';
import { getScenario } from '../scenarios/index.js';
@@ -36,6 +37,7 @@ export class GmShell extends HTMLElement {
<con-hub hidden></con-hub>
<director-rail></director-rail>
<main class="stage">
<run-board></run-board>
<dice-tray hidden></dice-tray>
<npc-tray hidden></npc-tray>
<art-tray hidden></art-tray>
@@ -203,6 +205,11 @@ export class GmShell extends HTMLElement {
onBreak: paused,
remainingMs: paused && this.breakEndsAt != null ? this.breakEndsAt - this.now() : null,
};
this.querySelector('run-board').update({
scenario: this.scenario,
elapsedMs: this.elapsedMs(),
stamps: this.stamps,
});
}
}
customElements.define('gm-shell', GmShell);
+54
View File
@@ -0,0 +1,54 @@
import { analyze } from '../core/timeline.js';
import { fmtElapsed, fmtDrift } from '../core/format.js';
import { escapeHtml } from '../core/escape-html.js';
// The always-there stage view behind the trays: the whole run at a glance —
// every beat with its target time, done beats stamped and dimmed, the current
// beat highlighted, hard triggers marked. Fed by the shell each refresh.
export class RunBoard extends HTMLElement {
constructor() {
super();
this._state = { scenario: null, elapsedMs: 0, stamps: {} };
}
connectedCallback() { this.render(); }
update(patch) { Object.assign(this._state, patch); this.render(); }
render() {
const { scenario, elapsedMs, stamps } = this._state;
if (!scenario) { this.innerHTML = ''; return; }
const m = analyze(scenario.timeline, elapsedMs / 60000, stamps);
const drift = fmtDrift(m.driftMin);
const driftClass = m.driftMin > 0 ? 'behind' : 'ahead';
// Highlight the next beat still to reach — the "you are here → next" cue.
const nextId = scenario.timeline.find((b) => stamps[b.id] == null)?.id;
const rows = scenario.timeline.map((b) => {
const done = stamps[b.id] != null;
const current = b.id === nextId;
const cls = done ? 'done' : current ? 'current' : 'upcoming';
const mark = done ? `✓ ${fmtElapsed((stamps[b.id] || 0) * 60000)}` : current ? '▶ next' : '';
return `
<div class="rb-beat ${cls}${b.hardTrigger ? ' hard' : ''}" data-beat-id="${escapeHtml(b.id)}">
<div class="rb-time">${fmtElapsed((b.targetMin || 0) * 60000)}</div>
<div class="rb-body">
<div class="rb-label">${escapeHtml(b.label)}${b.hardTrigger ? ' <span class="rb-hard">hard</span>' : ''}</div>
${b.cutHint ? `<div class="rb-cut">cut: ${escapeHtml(b.cutHint)}</div>` : ''}
</div>
<div class="rb-mark">${mark}</div>
</div>`;
}).join('');
const meta = [scenario.meta.slot, scenario.meta.playMinutes ? `${scenario.meta.playMinutes}m` : null]
.filter(Boolean).map(escapeHtml).join(' · ');
this.innerHTML = `
<div class="run-board">
<div class="rb-head">
<span class="rb-title">${escapeHtml(scenario.meta.title || '')}</span>
<span class="rb-meta">${meta}${drift ? ` · <span class="rb-drift ${driftClass}">${escapeHtml(drift)}</span>` : ''}</span>
</div>
<div class="rb-list">${rows}</div>
</div>`;
}
}
customElements.define('run-board', RunBoard);
+70 -9
View File
@@ -3,20 +3,24 @@
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b0e13; color: #e7eef6; }
gm-shell { display: block; }
.rail { display: flex; align-items: stretch; height: 64px; background: linear-gradient(180deg,#12171f,#0d1117);
.rail { display: flex; align-items: stretch; min-height: 72px; background: linear-gradient(180deg,#12171f,#0d1117);
border-bottom: 1px solid #263041; }
.rail .cell { padding: 7px 14px; display: flex; flex-direction: column; justify-content: center; border-right: 1px solid #1e2733; }
.rail .here { flex: 1; }
.rail .k { font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: #6b7a8d; }
.rail .v { font-size: 15px; font-weight: 600; }
.rail .cell { padding: 8px 16px; display: flex; flex-direction: column; justify-content: center; border-right: 1px solid #1e2733; min-width: 0; }
.rail .here { flex: 3 1 0; }
.rail .trig { flex: 2 1 0; }
.rail .k { font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: #6b7a8d; margin-bottom: 2px; }
.rail .v { font-size: 15px; font-weight: 600; line-height: 1.25; }
/* Clamp long beat / trigger labels to two lines — never let them bleed or clip the bar. */
.rail .here .v, .rail .trig .v { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.rail [data-role=clock] { font-variant-numeric: tabular-nums; }
.rail .trig .v { color: #ffd479; }
.drift { margin-left: 6px; padding: 1px 7px; border-radius: 20px; font-size: 11px; font-weight: 700; }
.drift { margin-left: 6px; padding: 1px 7px; border-radius: 20px; font-size: 11px; font-weight: 700; white-space: nowrap; }
.drift.behind { background: #4a1f1f; color: #ff9c9c; }
.drift.ahead { background: #1f3a2a; color: #9cffc4; }
.reached { border: none; padding: 0 18px; background: #1c8a5a; color: #eafff4; font-weight: 700; font-size: 14px; }
.tray-btn { border: none; width: 60px; background: #182130; color: #cfe0f0; font-size: 20px; }
.reached { border: none; padding: 0 20px; background: #1c8a5a; color: #eafff4; font-weight: 700; font-size: 14px; white-space: nowrap; flex: 0 0 auto; }
.tray-btn { border: none; width: 52px; background: #182130; color: #cfe0f0; font-size: 20px; flex: 0 0 auto; }
.stage { position: relative; min-height: calc(100vh - 64px); }
.stage { position: relative; min-height: calc(100vh - 72px); }
.dice-tray { position: absolute; top: 0; left: 0; right: 0; background: #0f141b; border-bottom: 1px solid #2b3644;
padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.dice-tray[hidden] { display: none; }
@@ -158,3 +162,60 @@ parking-lot[hidden] { display: none; }
.art-endpoint { flex: 1; background: #161d27; border: 1px solid #2a3542; border-radius: 8px; padding: 9px 11px; color: #e7eef6; font-size: 12px; }
.art-endpoint-save { border: none; padding: 9px 16px; border-radius: 8px; background: #1e3a52; color: #dcefff; font-weight: 700; font-size: 12px; }
.thumb--gen { outline: 1px solid #3f7fb0; }
/* ===== iPad / large-screen layout pass ===== */
/* Open trays fill the whole stage as solid panels (not a thin top strip). */
.dice-tray, .npc-tray, .art-tray, .clue-net, .cast-tray, .break-timer, .parking-lot {
inset: 0; overflow-y: auto; padding: 18px 20px;
}
/* Run board — the always-there stage view behind the trays (the whole run at a glance). */
.run-board { padding: 20px 22px 32px; max-width: 960px; margin: 0 auto; }
.rb-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
.rb-title { font-size: 21px; font-weight: 800; letter-spacing: .01em; }
.rb-meta { font-size: 13px; color: #8fa1b4; }
.rb-drift.behind { color: #ff9c9c; font-weight: 700; }
.rb-drift.ahead { color: #9cffc4; font-weight: 700; }
.rb-list { display: flex; flex-direction: column; gap: 7px; }
.rb-beat { display: flex; gap: 14px; align-items: flex-start; padding: 11px 14px; border: 1px solid #1e2733; border-radius: 10px; }
.rb-beat.done { opacity: .5; }
.rb-beat.current { border-color: #2f7d54; background: #12211a; }
.rb-time { font-variant-numeric: tabular-nums; font-weight: 700; color: #9fb0c2; min-width: 54px; flex: 0 0 auto; }
.rb-beat.hard .rb-time { color: #ffd479; }
.rb-body { flex: 1; min-width: 0; }
.rb-label { font-size: 14px; color: #e7eef6; line-height: 1.35; }
.rb-hard { font-size: 9px; text-transform: uppercase; letter-spacing: .08em; color: #ffd479; border: 1px solid #4a3a1f; border-radius: 10px; padding: 1px 6px; white-space: nowrap; }
.rb-cut { font-size: 12px; color: #8fa1b4; margin-top: 4px; }
.rb-mark { margin-left: auto; font-size: 12px; font-weight: 700; color: #8ff0b6; white-space: nowrap; flex: 0 0 auto; }
.rb-beat.current .rb-mark { color: #9cffc4; }
/* Roomier, more legible at tablet width; use the horizontal space. */
@media (min-width: 1024px) {
.rail { min-height: 82px; }
.stage { min-height: calc(100vh - 82px); }
.rail .k { font-size: 11px; }
.rail .v { font-size: 17px; }
.rail [data-role=clock] { font-size: 20px; }
.tray-btn { width: 62px; font-size: 23px; }
.reached { font-size: 16px; padding: 0 26px; }
.run-board { padding: 28px 26px 40px; }
.rb-title { font-size: 24px; }
.rb-label { font-size: 16px; }
.rb-time, .rb-mark, .rb-meta, .rb-cut { font-size: 14px; }
.rb-beat { padding: 14px 16px; }
.clue-row, .cast-row, .pl-note { padding: 13px 15px; }
.clue-label, .cast-name, .pl-text { font-size: 15px; }
.clue-fallback, .cast-note, .pl-at, .cast-secret { font-size: 13px; }
.clue-banner { font-size: 15px; }
.art-results { grid-template-columns: repeat(5, 1fr); }
.break-status { font-size: 28px; }
.break-dur, .break-start, .break-end { font-size: 16px; padding: 14px 18px; }
}
@media (min-width: 1366px) {
.run-board { max-width: 1100px; }
.art-results { grid-template-columns: repeat(6, 1fr); }
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/run-board.js';
const SCENARIO = {
meta: { title: 'DEMO', slot: 'Fri · Slot 1', playMinutes: 210 },
timeline: [
{ id: 'a', label: 'Opening', targetMin: 0 },
{ id: 'b', label: 'The market breaks', targetMin: 60, hardTrigger: true, cutHint: 'skip the tea' },
{ id: 'c', label: 'The reveal', targetMin: 120, hardTrigger: true },
{ id: 'd', label: 'Epilogue', targetMin: 200 },
],
};
describe('<run-board>', () => {
let el;
beforeEach(() => {
document.body.innerHTML = '';
el = document.createElement('run-board');
document.body.appendChild(el);
});
it('renders nothing without a scenario', () => {
expect(el.innerHTML.trim()).toBe('');
});
it('renders a row per beat with target times and the scenario header', () => {
el.update({ scenario: SCENARIO, elapsedMs: 0, stamps: {} });
expect(el.querySelectorAll('[data-beat-id]').length).toBe(4);
expect(el.querySelector('.rb-title').textContent).toBe('DEMO');
expect(el.querySelector('[data-beat-id="b"] .rb-time').textContent).toBe('1:00');
expect(el.querySelector('[data-beat-id="b"] .rb-hard')).not.toBe(null);
expect(el.querySelector('[data-beat-id="b"] .rb-cut').textContent).toContain('skip the tea');
});
it('marks done beats with their stamped time and dims them', () => {
el.update({ scenario: SCENARIO, elapsedMs: 90 * 60000, stamps: { a: 2, b: 58 } });
const b = el.querySelector('[data-beat-id="b"]');
expect(b.classList.contains('done')).toBe(true);
expect(b.querySelector('.rb-mark').textContent).toContain('0:58');
});
it('highlights the next unreached beat as "next"', () => {
el.update({ scenario: SCENARIO, elapsedMs: 90 * 60000, stamps: { a: 2, b: 58 } });
const current = el.querySelector('.rb-beat.current');
expect(current.dataset.beatId).toBe('c'); // first without a stamp
expect(current.querySelector('.rb-mark').textContent).toContain('next');
expect(el.querySelector('[data-beat-id="a"].current')).toBe(null);
});
it('shows drift in the header when behind/ahead', () => {
// reached the opening 6 min late → behind
el.update({ scenario: SCENARIO, elapsedMs: 6 * 60000, stamps: { a: 6 } });
expect(el.querySelector('.rb-drift')).not.toBe(null);
});
it('escapes author text in labels', () => {
el.update({ scenario: { meta: { title: 'x' }, timeline: [{ id: 'x', label: 'evil <b>', targetMin: 0 }] }, elapsedMs: 0, stamps: {} });
expect(el.querySelector('[data-beat-id="x"]').innerHTML).not.toContain('<b>');
});
});