From 0ec09290ea465f3c95fb67f556c9266cbc10c35b Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:41:03 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20iPad=20layout=20=E2=80=94=20clamp=20rail?= =?UTF-8?q?=20labels,=20fill=20the=20stage=20with=20a=20run-board?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 : 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 --- src/components/gm-shell.js | 7 +++ src/components/run-board.js | 54 ++++++++++++++++++++ src/styles.css | 79 ++++++++++++++++++++++++++---- tests/components/run-board.test.js | 60 +++++++++++++++++++++++ 4 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 src/components/run-board.js create mode 100644 tests/components/run-board.test.js diff --git a/src/components/gm-shell.js b/src/components/gm-shell.js index 121b995..87f3654 100644 --- a/src/components/gm-shell.js +++ b/src/components/gm-shell.js @@ -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 {
+ @@ -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); diff --git a/src/components/run-board.js b/src/components/run-board.js new file mode 100644 index 0000000..ab3e3e2 --- /dev/null +++ b/src/components/run-board.js @@ -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 ` +
+
${fmtElapsed((b.targetMin || 0) * 60000)}
+
+
${escapeHtml(b.label)}${b.hardTrigger ? ' hard' : ''}
+ ${b.cutHint ? `
cut: ${escapeHtml(b.cutHint)}
` : ''} +
+
${mark}
+
`; + }).join(''); + + const meta = [scenario.meta.slot, scenario.meta.playMinutes ? `${scenario.meta.playMinutes}m` : null] + .filter(Boolean).map(escapeHtml).join(' · '); + + this.innerHTML = ` +
+
+ ${escapeHtml(scenario.meta.title || '')} + ${meta}${drift ? ` · ${escapeHtml(drift)}` : ''} +
+
${rows}
+
`; + } +} +customElements.define('run-board', RunBoard); diff --git a/src/styles.css b/src/styles.css index 8902c81..ee86ee7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); } +} diff --git a/tests/components/run-board.test.js b/tests/components/run-board.test.js new file mode 100644 index 0000000..09672eb --- /dev/null +++ b/tests/components/run-board.test.js @@ -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('', () => { + 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 ', targetMin: 0 }] }, elapsedMs: 0, stamps: {} }); + expect(el.querySelector('[data-beat-id="x"]').innerHTML).not.toContain(''); + }); +});