From b57880d630623f5502c5e13aef5df99e9f1a4f65 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:35:47 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Slice=205=20=E2=80=94=20convention=20hu?= =?UTF-8?q?b=20(live=20now=20/=20up=20next,=20deep-links)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the convention hub landing screen: a pure schedule engine (analyzeSchedule) classifies each slot as live/next/done/upcoming from an injected now; renders the slate + summary and emits open-scenario; a scenario registry resolves a slot's scenarioId to its module; gm-shell routes hub<->session (hiding/restoring the Rail) with a home rail chip. Ships continuum-2026.js with the real slate (only AFTERIMAGE ported; others scenarioId:null, start-times TBC) and a fully-timed example-con.js template/fixture. 122 tests pass; build clean. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++- .../plans/2026-07-20-director-slice5.md | 49 ++++++++++++++ src/components/con-hub.js | 66 +++++++++++++++++++ src/components/director-rail.js | 4 ++ src/components/gm-shell.js | 33 ++++++++++ src/con/continuum-2026.js | 16 +++++ src/con/example-con.js | 13 ++++ src/con/schedule.js | 39 +++++++++++ src/main.js | 4 +- src/scenarios/index.js | 13 ++++ src/styles.css | 29 ++++++++ tests/components/con-hub.test.js | 57 ++++++++++++++++ tests/components/gm-shell-hub.test.js | 48 ++++++++++++++ tests/con/schedule.test.js | 57 ++++++++++++++++ tests/scenarios/registry.test.js | 14 ++++ 15 files changed, 446 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-20-director-slice5.md create mode 100644 src/components/con-hub.js create mode 100644 src/con/continuum-2026.js create mode 100644 src/con/example-con.js create mode 100644 src/con/schedule.js create mode 100644 src/scenarios/index.js create mode 100644 tests/components/con-hub.test.js create mode 100644 tests/components/gm-shell-hub.test.js create mode 100644 tests/con/schedule.test.js create mode 100644 tests/scenarios/registry.test.js diff --git a/README.md b/README.md index 36250b7..fcbf095 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ Components are dumb renderers that emit events; the shell wires them together. | `src/npc/` | NPC generator (`generator.js`) + genre table packs (`packs/noir.js`) | | `src/art/` | Pencil-art library search (`search.js`) + tagged manifest | | `src/clues/safety-net.js` | Clue safety-net engine (essential-gap + solvability) | +| `src/con/schedule.js` | Pure convention-schedule analysis (live / up-next / done / upcoming) | +| `src/components/con-hub.js` | `` โ€” the convention landing screen; deep-links into a scenario | +| `src/scenarios/index.js` | Scenario registry (`getScenario(id)`) the hub deep-links through | +| `src/con/` | Convention data (`continuum-2026.js`; `example-con.js` is a fully-timed template) | | `src/components/director-rail.js` | `` โ€” the always-on pacing bar (๐ŸŽฒ ๐Ÿ‘ค โœ๏ธ ๐Ÿ” chips) | | `src/components/dice-tray.js` | `` โ€” pack selector + die picker + roll + verdict | | `src/components/npc-tray.js` ยท `art-tray.js` ยท `prop-viewer.js` ยท `clue-net.js` | The tray tools | @@ -92,8 +96,8 @@ compress if you reach a beat behind schedule. 2. **Slice 2** โ€” NPC generator (offline genre tables) + art generator (offline pencil-art library). โœ… 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 "up next / live now"). -6. **Later** โ€” remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; scenario porting from the Continuum docs; markdown โ†’ scenario-data generator; native iPad wrapper. +5. **Slice 5** โ€” the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). โœ… +6. **Later** โ€” remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; scenario porting from the Continuum docs; real slot start-times for the hub; markdown โ†’ scenario-data generator; native iPad wrapper. ## Docs diff --git a/docs/superpowers/plans/2026-07-20-director-slice5.md b/docs/superpowers/plans/2026-07-20-director-slice5.md new file mode 100644 index 0000000..f07aea8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-director-slice5.md @@ -0,0 +1,49 @@ +# The Director โ€” Slice 5 Implementation Plan + +**Goal:** Add the convention **hub** โ€” a landing screen listing every slot of a convention with +live **"live now / up next / done / upcoming"** status, that deep-links into each ported scenario +and lets the GM return to the hub from a running session. + +**Architecture:** Same pattern as the other slices โ€” a pure engine (`analyzeSchedule`) that classifies +slots from a wall-clock `now`, a `` light-DOM component that renders the slot list + summary +and emits `open-scenario`, a scenario **registry** (`src/scenarios/index.js`) resolving a slot's +`scenarioId` to its module, and shell routing that switches between the hub and a session (hiding the +Rail/stage in hub mode) plus a ๐Ÿ  rail chip to return. No fabricated content: only AFTERIMAGE is +ported, so `continuum-2026.js` marks the other four slots `scenarioId: null` ("Not yet ported") with +`startsAt: null` (times TBC, not invented); a clearly-labelled `example-con.js` provides fully-timed +slots as the demo/test fixture. + +**Tech Stack:** JavaScript (ES modules, no TypeScript), Web Components (light DOM), Vite, Vitest + +jsdom. No new dependencies. Fully offline. + +## Global Constraints + +- **Offline-first, no runtime framework, light DOM, no TypeScript.** +- **Con model:** `{ meta: { id, title }, slots: [ { id, scenarioId, title, slot, system?, players?, playMinutes?, startsAt? } ] }`. + `slot` is the display label; `startsAt` (ISO) drives timing; `scenarioId` deep-links via the registry + (null โ‡’ not yet ported). A null/absent `startsAt` โ‡’ unscheduled: listed, never live/next. +- **Determinism:** `analyzeSchedule(slots, nowMs)` is pure; `` takes an injected `now()` like the shell. +- **Escaping:** all author text (titles, slot labels, systems) via `escapeHtml`. +- **Rail invariant preserved:** the hub is a *pre-session* screen; the Rail is hidden in hub mode and + restored (never removed) when a session is open. + +## Tasks (all complete) + +1. **Schedule engine** โ€” `src/con/schedule.js` (`analyzeSchedule`, `slotStartMs`) + `tests/con/schedule.test.js` (6). +2. **Scenario registry** โ€” `src/scenarios/index.js` (`SCENARIOS`, `getScenario`) + `tests/scenarios/registry.test.js` (2). +3. **`` component** โ€” `src/components/con-hub.js` + `tests/components/con-hub.test.js` (6). +4. **Con data** โ€” `src/con/continuum-2026.js` (real slate, honest placeholders) + `src/con/example-con.js` (template/fixture). +5. **Shell + rail wiring** โ€” `gm-shell.js` (`loadCon`/`showHub`/`showSession`/`onOpenScenario`, `open-hub`), + `director-rail.js` (๐Ÿ  chip), `styles.css`, boot in `main.js` โ†’ `loadCon(continuum2026)` + + `tests/components/gm-shell-hub.test.js` (4). + +## Validation + +- `npm test` โ†’ 122 passing (34 files); build succeeds (35 modules). Manual: hub renders the Continuum + slate, "Open" deep-links AFTERIMAGE into a live Rail session, ๐Ÿ  returns to the hub. Console clean. + +## Deferred (backlog, not this slice) + +- Real slot **start times** for Continuum 2026 (add each `startsAt` to light up live/up-next). +- Porting the four remaining scenarios (Day One, Vain Crown, Another Fine Mess, Prince's Bride) into + scenario modules + registering them (then flip each slot's `scenarioId`). diff --git a/src/components/con-hub.js b/src/components/con-hub.js new file mode 100644 index 0000000..2b80b48 --- /dev/null +++ b/src/components/con-hub.js @@ -0,0 +1,66 @@ +import { analyzeSchedule } from '../con/schedule.js'; +import { escapeHtml } from '../core/escape-html.js'; + +const STATUS_LABEL = { live: 'Live now', next: 'Up next', done: 'Done', upcoming: 'Upcoming' }; + +export class ConHub extends HTMLElement { + constructor() { + super(); + this._con = null; + this.now = () => Date.now(); + } + connectedCallback() { this.render(); } + + set con(v) { this._con = v || null; this.render(); } + get con() { return this._con; } + + render() { + if (!this._con) { + this.innerHTML = `
No convention loaded
`; + return; + } + const con = this._con; + const a = analyzeSchedule(con.slots || [], this.now()); + + const summary = a.liveNow + ? `โ— Live now โ€” ${escapeHtml(a.liveNow.title)}` + : (a.upNext + ? `Up next โ€” ${escapeHtml(a.upNext.title)}` + : `No sessions scheduled`); + + const cards = a.slots.map((s) => { + const canOpen = !!s.scenarioId; + const meta = [s.slot, s.system, s.players ? `${s.players}p` : null] + .filter(Boolean).map(escapeHtml).join(' ยท '); + const action = canOpen + ? `` + : `Not yet ported`; + return ` +
+
+
${escapeHtml(s.title)} ${STATUS_LABEL[s.status]}
+
${meta}
+
+ ${action} +
`; + }).join(''); + + this.innerHTML = ` +
+
+

${escapeHtml(con.meta?.title || 'Convention')}

+
${summary}
+
+
${cards}
+
`; + + this.querySelectorAll('[data-role=open]').forEach((btn) => { + btn.addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('open-scenario', { + detail: { scenarioId: btn.dataset.scenarioId }, bubbles: true, + })); + }); + }); + } +} +customElements.define('con-hub', ConHub); diff --git a/src/components/director-rail.js b/src/components/director-rail.js index 567bae6..68cf907 100644 --- a/src/components/director-rail.js +++ b/src/components/director-rail.js @@ -26,6 +26,7 @@ export class DirectorRail extends HTMLElement { ${here}${drift ? ` ${drift}` : ''}
Next hard trigger${nhText}
+ @@ -35,6 +36,9 @@ export class DirectorRail extends HTMLElement { this.querySelector('[data-role=reached]').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('reached', { bubbles: true })); }); + this.querySelector('[data-role=open-hub]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('open-hub', { bubbles: true })); + }); this.querySelector('[data-role=open-dice]').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true })); }); diff --git a/src/components/gm-shell.js b/src/components/gm-shell.js index c5aebe0..2e2859e 100644 --- a/src/components/gm-shell.js +++ b/src/components/gm-shell.js @@ -7,12 +7,15 @@ import './npc-tray.js'; import './art-tray.js'; import './prop-viewer.js'; import './clue-net.js'; +import './con-hub.js'; +import { getScenario } from '../scenarios/index.js'; export class GmShell extends HTMLElement { constructor() { super(); this.now = () => Date.now(); this.scenario = null; + this.con = null; this.store = null; this.clockState = initialClock(); this.stamps = {}; @@ -23,6 +26,7 @@ export class GmShell extends HTMLElement { connectedCallback() { this.innerHTML = ` +
@@ -36,6 +40,35 @@ export class GmShell extends HTMLElement { this.addEventListener('keep-npc', (e) => this.onKeepNpc(e)); this.addEventListener('show-prop', (e) => this.onShowProp(e)); this.addEventListener('toggle-clue', (e) => this.onToggleClue(e)); + this.addEventListener('open-scenario', (e) => this.onOpenScenario(e)); + this.addEventListener('open-hub', () => this.showHub()); + } + + loadCon(con) { + this.con = con; + const hub = this.querySelector('con-hub'); + hub.now = this.now; + hub.con = con; + this.showHub(); + } + + showHub() { + this.querySelector('con-hub').hidden = false; + this.querySelector('director-rail').hidden = true; + this.querySelector('.stage').hidden = true; + } + + showSession() { + this.querySelector('con-hub').hidden = true; + this.querySelector('director-rail').hidden = false; + this.querySelector('.stage').hidden = false; + } + + onOpenScenario(e) { + const scenario = getScenario(e.detail?.scenarioId); + if (!scenario) return; + this.loadScenario(scenario); + this.showSession(); } loadScenario(scenario) { diff --git a/src/con/continuum-2026.js b/src/con/continuum-2026.js new file mode 100644 index 0000000..355718e --- /dev/null +++ b/src/con/continuum-2026.js @@ -0,0 +1,16 @@ +// Continuum 2026 โ€” the real convention this build is first deployed for. +// Only AFTERIMAGE is ported into a scenario module today (scenarioId set); the +// other four slots are known slate metadata with scenarioId: null until they are +// ported (see the "scenario porting" backlog item). Slot start times are left +// null (TBC) rather than invented โ€” add each slot's real `startsAt` (ISO local +// time) to light up the live / up-next timing on the hub. +export default { + meta: { id: 'continuum-2026', title: 'Continuum 2026' }, + slots: [ + { id: 'c26-fri-1', scenarioId: null, title: 'Day One', slot: 'Fri ยท Slot 1', startsAt: null }, + { id: 'c26-fri-2', scenarioId: 'afterimage', title: 'AFTERIMAGE', slot: 'Fri ยท Slot 2', system: 'year-zero', players: 4, playMinutes: 210, startsAt: null }, + { id: 'c26-sat-4', scenarioId: null, title: 'Vain Crown', slot: 'Sat ยท Slot 4', startsAt: null }, + { id: 'c26-sun-6', scenarioId: null, title: 'Another Fine Mess', slot: 'Sun ยท Slot 6', system: 'coc-d100', startsAt: null }, + { id: 'c26-sun-7', scenarioId: null, title: "Prince's Bride", slot: 'Sun ยท Slot 7', system: 'dee-sanction', startsAt: null }, + ], +}; diff --git a/src/con/example-con.js b/src/con/example-con.js new file mode 100644 index 0000000..c1c05ca --- /dev/null +++ b/src/con/example-con.js @@ -0,0 +1,13 @@ +// TEMPLATE / EXAMPLE โ€” not a real convention. Demonstrates the con-schedule +// model with fully-timed slots so live / up-next / done statuses are visible. +// A slot's `scenarioId` deep-links into a registered scenario module +// (src/scenarios/index.js); `slot` is the display label, `startsAt` (ISO) drives +// the live/next timing. Copy this shape into a real con file. +export default { + meta: { id: 'example-con', title: 'Example Convention' }, + slots: [ + { id: 'ex-fri-1', scenarioId: 'afterimage', title: 'AFTERIMAGE', slot: 'Fri ยท Slot 1 ยท 10:00', system: 'year-zero', players: 4, playMinutes: 210, startsAt: '2026-08-14T10:00:00' }, + { id: 'ex-fri-2', scenarioId: 'example-clues', title: 'Example โ€” Clue Net', slot: 'Fri ยท Slot 2 ยท 14:30', system: 'year-zero', players: 4, playMinutes: 210, startsAt: '2026-08-14T14:30:00' }, + { id: 'ex-sat-1', scenarioId: null, title: 'Unported Demo Slot', slot: 'Sat ยท Slot 1 ยท 10:00', playMinutes: 210, startsAt: '2026-08-15T10:00:00' }, + ], +}; diff --git a/src/con/schedule.js b/src/con/schedule.js new file mode 100644 index 0000000..fdc648c --- /dev/null +++ b/src/con/schedule.js @@ -0,0 +1,39 @@ +// Pure convention-schedule analysis. Given slots and a wall-clock `nowMs`, +// classify each slot as done | live | next | upcoming and surface the live/up-next. +// Slots carry `startsAt` (ISO string) for timing logic; a null/absent start is +// treated as unscheduled (sorted last, never live/next) so the hub still lists it. +export function slotStartMs(slot) { + if (typeof slot.startMs === 'number') return slot.startMs; + if (slot.startsAt) { + const t = Date.parse(slot.startsAt); + if (!Number.isNaN(t)) return t; + } + return Infinity; +} + +export function analyzeSchedule(slots = [], nowMs = 0) { + const enriched = slots + .map((s) => { + const startMs = slotStartMs(s); + const endMs = startMs === Infinity ? Infinity : startMs + (s.playMinutes ?? 0) * 60000; + return { ...s, startMs, endMs }; + }) + .sort((a, b) => a.startMs - b.startMs); + + let nextAssigned = false; + const outSlots = enriched.map((s) => { + let status; + if (s.startMs === Infinity) status = 'upcoming'; + else if (nowMs >= s.endMs) status = 'done'; + else if (nowMs >= s.startMs) status = 'live'; + else if (!nextAssigned) { status = 'next'; nextAssigned = true; } + else status = 'upcoming'; + return { ...s, status }; + }); + + return { + slots: outSlots, + liveNow: outSlots.find((s) => s.status === 'live') || null, + upNext: outSlots.find((s) => s.status === 'next') || null, + }; +} diff --git a/src/main.js b/src/main.js index 0c81ce3..2e318c4 100644 --- a/src/main.js +++ b/src/main.js @@ -1,13 +1,13 @@ // Boot entry. import './components/gm-shell.js'; -import afterimage from './scenarios/afterimage.js'; +import continuum2026 from './con/continuum-2026.js'; export const APP = 'the-director'; if (typeof document !== 'undefined' && document.querySelector('gm-shell')) { customElements.whenDefined('gm-shell').then(() => { const shell = document.querySelector('gm-shell'); - shell.loadScenario(afterimage); + shell.loadCon(continuum2026); }); } diff --git a/src/scenarios/index.js b/src/scenarios/index.js new file mode 100644 index 0000000..fadf44a --- /dev/null +++ b/src/scenarios/index.js @@ -0,0 +1,13 @@ +// Scenario registry โ€” maps a scenario id to its module so the con hub can +// deep-link a slot into the shell. Register each new scenario module here. +import afterimage from './afterimage.js'; +import exampleWithClues from './example-with-clues.js'; + +export const SCENARIOS = { + [afterimage.meta.id]: afterimage, + [exampleWithClues.meta.id]: exampleWithClues, +}; + +export function getScenario(id) { + return SCENARIOS[id] || null; +} diff --git a/src/styles.css b/src/styles.css index ed3162b..82bcb5e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -78,3 +78,32 @@ clue-net[hidden] { display: none; } .clue-act { font-size: 10px; color: #7f8ea0; } .clue-fallback { margin-top: 4px; font-size: 12px; color: #ff9c9c; } .clue-empty { color: #6b7a8d; font-size: 12px; } + +/* Slice 5 โ€” convention hub */ +con-hub[hidden] { display: none; } +director-rail[hidden] { display: none; } +.stage[hidden] { display: none; } +.hub { padding: 20px 16px 32px; max-width: 720px; margin: 0 auto; } +.hub--empty { color: #6b7a8d; font-size: 13px; padding: 24px 16px; } +.hub-head { margin-bottom: 16px; } +.hub-title { font-size: 22px; font-weight: 800; margin: 0 0 6px; letter-spacing: .01em; } +.hub-summary { font-size: 13px; font-weight: 700; } +.hub-summary .hub-live { color: #8ff0b6; } +.hub-summary .hub-next { color: #ffd479; } +.hub-summary .hub-idle { color: #7f8ea0; } +.hub-list { display: flex; flex-direction: column; gap: 10px; } +.hub-card { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid #263041; + border-radius: 12px; background: #12171f; } +.hub-card.status-live { border-color: #2f7d54; background: #12211a; } +.hub-card.status-next { border-color: #4a3a1f; } +.hub-card.status-done { opacity: .55; } +.hub-card-main { flex: 1; min-width: 0; } +.hub-card-title { font-size: 16px; font-weight: 700; color: #e7eef6; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.hub-card-meta { font-size: 12px; color: #8fa1b4; margin-top: 3px; } +.hub-badge { font-size: 9px; text-transform: uppercase; letter-spacing: .08em; border-radius: 12px; padding: 2px 8px; + background: #1b2431; color: #9fb0c2; border: 1px solid #2a3542; } +.hub-badge.badge-live { background: #17251c; color: #8ff0b6; border-color: #2f7d54; } +.hub-badge.badge-next { background: #2a2013; color: #ffd479; border-color: #4a3a1f; } +.hub-open { border: none; padding: 10px 20px; border-radius: 9px; background: #1c8a5a; color: #eafff4; + font-weight: 700; font-size: 14px; flex: 0 0 auto; } +.hub-soon { font-size: 11px; color: #6b7a8d; flex: 0 0 auto; } diff --git a/tests/components/con-hub.test.js b/tests/components/con-hub.test.js new file mode 100644 index 0000000..2e85a19 --- /dev/null +++ b/tests/components/con-hub.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/con-hub.js'; + +const CON = { + meta: { id: 'demo', title: 'Demo Con' }, + slots: [ + { id: 's1', scenarioId: 'a', title: 'Past Game', slot: 'Fri ยท S1', system: 'year-zero', players: 4, playMinutes: 60, startsAt: '2026-07-24T09:00:00Z' }, + { id: 's2', scenarioId: 'b', title: 'Now Game', slot: 'Fri ยท S2', system: 'coc-d100', players: 5, playMinutes: 60, startsAt: '2026-07-24T12:00:00Z' }, + { id: 's3', scenarioId: null, title: 'Unported', slot: 'Sat ยท S4', playMinutes: 60, startsAt: '2026-07-25T09:00:00Z' }, + ], +}; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('con-hub'); + el.now = () => Date.parse('2026-07-24T12:30:00Z'); + document.body.appendChild(el); + }); + + it('shows an empty state with no con', () => { + expect(el.querySelector('[data-role=empty]')).not.toBe(null); + }); + + it('renders a card per slot with its status', () => { + el.con = CON; + expect(el.querySelectorAll('[data-slot-id]').length).toBe(3); + expect(el.querySelector('[data-slot-id=s1]').dataset.status).toBe('done'); + expect(el.querySelector('[data-slot-id=s2]').dataset.status).toBe('live'); + expect(el.querySelector('[data-slot-id=s3]').dataset.status).toBe('next'); + }); + + it('summarises the live session', () => { + el.con = CON; + expect(el.querySelector('[data-role=summary]').textContent).toContain('Now Game'); + }); + + it('emits open-scenario for a ported slot', () => { + el.con = CON; + let detail = null; + el.addEventListener('open-scenario', (e) => { detail = e.detail; }); + el.querySelector('[data-slot-id=s2] [data-role=open]').click(); + expect(detail).toEqual({ scenarioId: 'b' }); + }); + + it('shows "not yet ported" and no open button for an unported slot', () => { + el.con = CON; + expect(el.querySelector('[data-slot-id=s3] [data-role=not-ready]')).not.toBe(null); + expect(el.querySelector('[data-slot-id=s3] [data-role=open]')).toBe(null); + }); + + it('escapes author text', () => { + el.con = { meta: { title: 'x' }, slots: [{ id: 'x', scenarioId: null, title: 'evil ', slot: 'a" ', playMinutes: 60 }] }; + expect(el.querySelector('[data-slot-id=x]').innerHTML).not.toContain(''); + }); +}); diff --git a/tests/components/gm-shell-hub.test.js b/tests/components/gm-shell-hub.test.js new file mode 100644 index 0000000..6112c5e --- /dev/null +++ b/tests/components/gm-shell-hub.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/gm-shell.js'; +import continuum from '../../src/con/continuum-2026.js'; + +describe(' convention hub', () => { + let el; + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + el = document.createElement('gm-shell'); + el.now = () => 0; + document.body.appendChild(el); + el.loadCon(continuum); + }); + + it('boots into the hub with the rail and stage hidden', () => { + expect(el.querySelector('con-hub').hidden).toBe(false); + expect(el.querySelector('director-rail').hidden).toBe(true); + expect(el.querySelector('.stage').hidden).toBe(true); + expect(el.querySelector('con-hub').con).toBe(continuum); + }); + + it('deep-links a ported slot into a session and hides the hub', () => { + el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'afterimage' }, bubbles: true })); + expect(el.scenario.meta.id).toBe('afterimage'); + expect(el.querySelector('con-hub').hidden).toBe(true); + expect(el.querySelector('director-rail').hidden).toBe(false); + expect(el.querySelector('.stage').hidden).toBe(false); + expect(el.querySelector('director-rail [data-role=here]').textContent).toContain('The pier'); + }); + + it('ignores open-scenario for an unknown / unported slot', () => { + el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'not-ported' }, bubbles: true })); + expect(el.scenario).toBe(null); + expect(el.querySelector('con-hub').hidden).toBe(false); + }); + + it('the rail hub chip emits open-hub, returning to the hub', () => { + el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'afterimage' }, bubbles: true })); + const rail = el.querySelector('director-rail'); + let fired = 0; + el.addEventListener('open-hub', () => fired++); + rail.querySelector('[data-role=open-hub]').click(); + expect(fired).toBe(1); + expect(el.querySelector('con-hub').hidden).toBe(false); + expect(rail.hidden).toBe(true); + }); +}); diff --git a/tests/con/schedule.test.js b/tests/con/schedule.test.js new file mode 100644 index 0000000..85a8cb9 --- /dev/null +++ b/tests/con/schedule.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeSchedule, slotStartMs } from '../../src/con/schedule.js'; + +const at = (iso) => Date.parse(iso); + +const SLOTS = [ + { id: 's1', title: 'Morning', playMinutes: 60, startsAt: '2026-07-24T09:00:00Z' }, + { id: 's2', title: 'Midday', playMinutes: 60, startsAt: '2026-07-24T12:00:00Z' }, + { id: 's3', title: 'Afternoon', playMinutes: 60, startsAt: '2026-07-24T15:00:00Z' }, + { id: 's4', title: 'Untimed', playMinutes: 60 }, +]; + +describe('analyzeSchedule', () => { + it('classifies done / live / next / upcoming around now', () => { + const r = analyzeSchedule(SLOTS, at('2026-07-24T12:30:00Z')); + const byId = Object.fromEntries(r.slots.map((s) => [s.id, s.status])); + expect(byId.s1).toBe('done'); // ended 10:00 + expect(byId.s2).toBe('live'); // 12:00โ€“13:00 + expect(byId.s3).toBe('next'); // first future timed slot + expect(byId.s4).toBe('upcoming'); // untimed, never next + expect(r.liveNow.id).toBe('s2'); + expect(r.upNext.id).toBe('s3'); + }); + + it('before the con: nothing live, earliest is next', () => { + const r = analyzeSchedule(SLOTS, at('2026-07-24T06:00:00Z')); + expect(r.liveNow).toBe(null); + expect(r.upNext.id).toBe('s1'); + expect(r.slots.find((s) => s.id === 's2').status).toBe('upcoming'); + }); + + it('after the con: everything done except the untimed slot', () => { + const r = analyzeSchedule(SLOTS, at('2026-07-25T00:00:00Z')); + expect(r.liveNow).toBe(null); + expect(r.upNext).toBe(null); + expect(r.slots.find((s) => s.id === 's1').status).toBe('done'); + expect(r.slots.find((s) => s.id === 's4').status).toBe('upcoming'); + }); + + it('sorts slots by start time, untimed last', () => { + const r = analyzeSchedule(SLOTS, 0); + expect(r.slots.map((s) => s.id)).toEqual(['s1', 's2', 's3', 's4']); + }); + + it('treats an empty schedule as idle', () => { + const r = analyzeSchedule([], 0); + expect(r.slots).toEqual([]); + expect(r.liveNow).toBe(null); + expect(r.upNext).toBe(null); + }); + + it('slotStartMs falls back to Infinity when unscheduled', () => { + expect(slotStartMs({})).toBe(Infinity); + expect(slotStartMs({ startMs: 5 })).toBe(5); + expect(slotStartMs({ startsAt: '2026-07-24T09:00:00Z' })).toBe(at('2026-07-24T09:00:00Z')); + }); +}); diff --git a/tests/scenarios/registry.test.js b/tests/scenarios/registry.test.js new file mode 100644 index 0000000..a6fa4a4 --- /dev/null +++ b/tests/scenarios/registry.test.js @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { SCENARIOS, getScenario } from '../../src/scenarios/index.js'; +import afterimage from '../../src/scenarios/afterimage.js'; + +describe('scenario registry', () => { + it('resolves a registered scenario by id', () => { + expect(getScenario('afterimage')).toBe(afterimage); + expect(SCENARIOS.afterimage).toBe(afterimage); + }); + + it('returns null for an unknown id', () => { + expect(getScenario('nope')).toBe(null); + }); +});