diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 6453c8e0..15d7ae42 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -260,6 +260,17 @@ function App() { // prop; the nav rail and every other screen are intentionally untouched by it. const liveSession = useLiveSession(state); + // "Building your universe" loading experience (building-universe.jsx). The launcher's + // startPlay / the Forge's bindHero stamp a sessionStorage "building" flag at the click; this + // hook reads it on mount so the full-screen loading overlay covers BOTH waits — the + // startProviderSession mint + the location.assign reload (the flag survives the reload) AND the + // cold-open. It hands off (clears) when the first DM narration beat lands in liveSession.chatBeats + // (the same real milestone the in-table cold-open clears on). Falls back gracefully if the + // bundle/hook is absent. + const building = (typeof window.useBuildingUniverse === "function") + ? window.useBuildingUniverse(liveSession) + : { active: false, record: null, handoff: false, dismiss: () => {} }; + React.useEffect(() => { document.documentElement.setAttribute("data-palette", t.palette || "warm"); }, [t.palette]); @@ -370,6 +381,21 @@ function App() { } }, [nativeState, screen]); + // Building→table handoff. The "building your universe" overlay clears (active → inactive) the + // moment the first DM narration beat lands — that beat is already in the chronicle, so land the + // player on the table to read it. This is belt-and-suspenders with didAutoRoute above (which + // covers the native runningProvider signal); it also handles the in-browser already-live case + // where the overlay was shown but no native provider status flips. Only redirects FROM the + // launcher, so a player who navigated mid-build is respected. + const wasBuilding = React.useRef(false); + React.useEffect(() => { + if (building.active) { wasBuilding.current = true; return; } + if (wasBuilding.current && screen === "launcher") { + setScreen("table"); + } + wasBuilding.current = false; + }, [building.active, screen]); + // During a live play session (a DM provider is attached), keep the active campaign bound to // the viewer's CURRENT (live) campaign. The DM mints this run's campaign a few seconds after // the page loads, so the initial catalog pick can be a stale save; once the re-poll surfaces @@ -460,6 +486,7 @@ function App() { { title: "Open Worlds", day: "" }; return ( +
)}
+ + {/* The full-screen "building your universe" loading overlay. position:fixed (styles.css), so + it covers the whole app — title bar, rail, stage — while the table boots underneath and + the app-level /chat poll keeps running. Clears itself when the first DM narration lands. */} + {building.active && window.BuildingUniverse && ( + + )} +
); } diff --git a/viewer/openworlds/building-universe.jsx b/viewer/openworlds/building-universe.jsx new file mode 100644 index 00000000..5d1a252b --- /dev/null +++ b/viewer/openworlds/building-universe.jsx @@ -0,0 +1,278 @@ +/* Building-Your-Universe — the full-screen "the world is being made" loading experience. + * + * THE WAIT IT COVERS (the owner's ask). Pressing Start / Resume → Play (launcher) or Bind + * (the Forge/Creation wizard) mints a DM provider session and then generates the cold-open — + * two long waits with a full page reload wedged BETWEEN them: + * (a) startProviderSession mints the run + the bridge returns a live viewer URL, then + * window.location.assign() RELOADS the page onto that fresh viewer; and + * (b) the reloaded live viewer boots and the DM composes the first beat (~30–90s, sometimes + * minutes — the engine is building the world + setting the scene; the /chat tail carries + * no streaming, so the first narration lands all-at-once). + * Before this, the player saw "nothing happens" then an abrupt read-only flash. This replaces + * BOTH waits with one intentional, on-brand (parchment/brass) loading state that PERSISTS across + * the mint, the reload, AND the cold-open, then hands off to the live table the instant the first + * DM narration arrives. + * + * HOW IT SURVIVES THE RELOAD. The "we are building a universe" intent is stamped into + * sessionStorage (NOT React state — React state dies with the page on location.assign). + * sessionStorage survives a same-tab navigation and is auto-dropped when the tab closes, so a + * stale flag can't leak into an unrelated future session. window.OpenWorldsBuilding is the tiny + * persistence facade; useBuildingUniverse (consumed by App) reads it on mount so the overlay is + * already up the moment the reloaded page paints — no blank gap. + * + * HOW IT HANDS OFF (honest, not faked). It does NOT guess a percentage. It detects the REAL + * milestone — the first DM narration beat — off the SAME signal the in-table cold-open uses: + * liveSession.chatBeats gaining a { kind: "narration" } entry (app.jsx's /chat poll). When that + * lands, the universe is built: we show a one-beat "Your story begins…" flourish, then clear the + * flag and let App route to the table where that very first beat is already in the chronicle. + * A 12-min hard backstop (matching the cold-open's PENDING_BACKSTOP_MS) guarantees the overlay can + * never wedge forever even if no beat ever comes. + * + * The bar of honesty (per the owner): animated "composing your opening…" + rotating lore flavor + + * a live elapsed readout. No fake progress bar we can't back. + */ + +// ---- persistence facade (survives location.assign) ------------------------------------------ +// One sessionStorage record describes the in-flight "build". begin() is called SYNCHRONOUSLY at +// the click — before startProviderSession — by both the launcher (startPlay) and the Forge +// (bindHero), so the overlay is up instantly, before the async bridge hop and the reload. +const OW_BUILDING_KEY = "openworlds.building"; +// A floor on how long the overlay lingers once "begun", so a fast-failing bridge call (no reload, +// instant reject) still shows the intent for a readable moment rather than a flash — but mostly +// this exists so begin()→immediate-error in a browser preview doesn't blink. The real lifetime is +// governed by the first-narration handoff and the hard backstop below. +const OW_BUILDING_BACKSTOP_MS = 12 * 60 * 1000; // mirrors app.jsx PENDING_BACKSTOP_MS (the cold-open ceiling) + +window.OpenWorldsBuilding = window.OpenWorldsBuilding || { + // Stamp the intent + announce it so a still-mounted App shows the overlay this tick (pre-reload). + begin(meta) { + const record = { + startedAt: Date.now(), + world: (meta && meta.world) || "", + title: (meta && meta.title) || "", + // "forge" | "play" — purely cosmetic (the eyebrow copy), never load-bearing. + kind: (meta && meta.kind) || "play", + }; + try { window.sessionStorage.setItem(OW_BUILDING_KEY, JSON.stringify(record)); } catch (_e) {} + try { window.dispatchEvent(new CustomEvent("clawdnd:building-begin", { detail: record })); } catch (_e) {} + return record; + }, + read() { + try { + const raw = window.sessionStorage.getItem(OW_BUILDING_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed.startedAt !== "number") return null; + // Self-heal: a record older than the hard backstop is stale (e.g. the tab was left on the + // overlay for 12+ min with no beat) — drop it so a fresh load doesn't re-enter the overlay. + if (Date.now() - parsed.startedAt > OW_BUILDING_BACKSTOP_MS) { + this.clear(); + return null; + } + return parsed; + } catch (_e) { + return null; + } + }, + clear() { + try { window.sessionStorage.removeItem(OW_BUILDING_KEY); } catch (_e) {} + }, + backstopMs: OW_BUILDING_BACKSTOP_MS, +}; + +// ---- rotating lore flavor (honest "the world is being assembled" cues) ----------------------- +// Three phases so the copy tracks the real arc of the wait (the world → the factions → your +// hero → it's almost ready), and so consecutive renders/snapshots DIFFER (the same proof-of-life +// lesson as #385: a single unchanging line reads as a frozen app to a screenshot AND the a11y +// tree). These are flavor, not status — they don't claim a step is "done", only that the world is +// coming together. The headline rotates every ~3.5s. +const BUILDING_FLAVOR = [ + "Assembling the Sword Coast…", + "Unrolling the map of Faerûn…", + "Lighting the lamps along the cobbled streets…", + "The Flaming Fist musters at the city gates…", + "Harpers trade whispers in shadowed taverns…", + "Thieves of the Guild count coin in the undercellars…", + "Your hero draws breath at the edge of the tale…", + "Fate shuffles the deck of your first encounter…", + "Gathering the threads of your story…", + "The Dungeon Master composes your opening scene…", + "The ink is still drying on your first page…", +]; + +// A short, calm sub-line that rotates more slowly — sets the expectation honestly. +const BUILDING_SUBLINE = [ + "Your world is being built. This first moment can take up to a minute.", + "The Dungeon Master is setting the stage — hang tight, your story is on its way.", + "Worlds are not made in an instant. The first scene is worth the wait.", +]; + +// ---- the App-level hook -------------------------------------------------------------------- +// Owns the overlay's lifecycle. Reads the persisted intent on mount (so a reloaded page shows the +// overlay immediately), listens for begin() (the pre-reload, same-page case), and HANDS OFF when +// the first DM narration beat lands in liveSession.chatBeats — the same real milestone the +// in-table cold-open clears on. Returns { active, record, dismiss }. +function useBuildingUniverse(liveSession) { + const [record, setRecord] = React.useState(() => window.OpenWorldsBuilding.read()); + // "handoff" is the brief flourish phase after the first beat lands but before we unmount — so the + // table doesn't pop in with a jarring cut; the player reads "Your story begins…" for a beat. + const [handoff, setHandoff] = React.useState(false); + const handoffTimer = React.useRef(null); + const backstopTimer = React.useRef(null); + + // begin() fired on THIS page (no reload yet — the launcher/forge click) → show immediately. + React.useEffect(() => { + const onBegin = (e) => { + setHandoff(false); + setRecord((e && e.detail) || window.OpenWorldsBuilding.read()); + }; + window.addEventListener("clawdnd:building-begin", onBegin); + return () => window.removeEventListener("clawdnd:building-begin", onBegin); + }, []); + + // Hard backstop: never let the overlay wedge forever. If no first beat arrives within the + // ceiling, clear the flag and dismiss (the table's own cold-open/stuck handling takes over). + React.useEffect(() => { + if (!record || handoff) return undefined; + const elapsed = Date.now() - (record.startedAt || Date.now()); + const remaining = Math.max(0, OW_BUILDING_BACKSTOP_MS - elapsed); + backstopTimer.current = window.setTimeout(() => { + window.OpenWorldsBuilding.clear(); + setRecord(null); + }, remaining); + return () => { + if (backstopTimer.current) { window.clearTimeout(backstopTimer.current); backstopTimer.current = null; } + }; + }, [record, handoff]); + + // THE HANDOFF. The first DM narration beat = the universe is built. Detect it off the live + // chat tail (the exact signal the cold-open pending clears on). Run the short flourish, then + // clear the flag + unmount so App routes to the table (where this beat is already in the log). + const hasFirstNarration = + Array.isArray(liveSession && liveSession.chatBeats) && + liveSession.chatBeats.some((b) => b && b.kind === "narration"); + + React.useEffect(() => { + if (!record || handoff) return undefined; + if (!hasFirstNarration) return undefined; + setHandoff(true); + handoffTimer.current = window.setTimeout(() => { + window.OpenWorldsBuilding.clear(); + setRecord(null); + setHandoff(false); + }, 1400); + return () => { + if (handoffTimer.current) { window.clearTimeout(handoffTimer.current); handoffTimer.current = null; } + }; + }, [record, handoff, hasFirstNarration]); + + React.useEffect(() => () => { + if (handoffTimer.current) window.clearTimeout(handoffTimer.current); + if (backstopTimer.current) window.clearTimeout(backstopTimer.current); + }, []); + + const dismiss = React.useCallback(() => { + window.OpenWorldsBuilding.clear(); + setRecord(null); + setHandoff(false); + }, []); + + return { active: Boolean(record), record, handoff, dismiss }; +} +window.useBuildingUniverse = useBuildingUniverse; + +// ---- the full-screen overlay ---------------------------------------------------------------- +// On-brand (parchment + brass + candleglow), animated (a rotating brass seal + an etched +// "assembling" progress sweep that is HONEST — it loops, it does not claim a percentage), a live +// elapsed readout, and rotating lore flavor. a11y: a single stable role="status" announces the +// wait ONCE (it never re-fires per tick); the ticking elapsed + rotating headline are visible and +// in the a11y tree (so a screenshot AND the accessibility snapshot both see motion — the #385 +// frozen-app lesson) but live OUTSIDE the announced region so a screen reader isn't spammed. +function BuildingUniverse({ record, handoff }) { + const start = (record && typeof record.startedAt === "number") ? record.startedAt : Date.now(); + const [now, setNow] = React.useState(() => Date.now()); + React.useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + const secs = Math.max(0, Math.floor((now - start) / 1000)); + const mm = Math.floor(secs / 60); + const ss = String(secs % 60).padStart(2, "0"); + const elapsedLabel = `${mm}:${ss}`; + + // Rotate the headline every ~3.5s and the subline every ~9s so both visibly change over the wait. + const headline = handoff + ? "Your story begins…" + : BUILDING_FLAVOR[Math.floor(secs / 3.5) % BUILDING_FLAVOR.length]; + const subline = handoff + ? "Stepping into the scene the Dungeon Master has set for you." + : BUILDING_SUBLINE[Math.floor(secs / 9) % BUILDING_SUBLINE.length]; + + const eyebrow = handoff + ? "The world awakens" + : (record && record.kind === "forge" ? "Binding your hero" : "Building your universe"); + + return ( +
+ {/* Announced ONCE — stable text, so the polite region does not re-fire every second. */} + + {handoff + ? "Your story is ready. Entering the table." + : "Building your universe. The Dungeon Master is composing your opening scene; this first moment can take up to a minute."} + + +
+ {/* The animated brass seal — the centerpiece "the world is being forged" motion. Two + counter-rotating rings + a breathing core glow. Decorative (aria-hidden); stilled under + reduced-motion via CSS. */} + + +
{eyebrow}
+ {/* Visible + in the a11y tree (NOT inside the announced region) so the rotating headline + + elapsed prove life on a screenshot AND in an aria snapshot, without per-tick spam. */} +

{headline}

+

{subline}

+ + {/* The honest "sweep" — an indeterminate, looping etched bar. It is explicitly NOT a + percentage; it conveys "work is ongoing", paired with the real elapsed clock beside it. */} + + +
+
+ ); +} +window.BuildingUniverse = BuildingUniverse; + +// Expose the flavor pools for tests / devtools introspection (purely additive — the component +// closes over the consts directly; nothing in the running app reads these off window). +window.BUILDING_FLAVOR = BUILDING_FLAVOR; +window.BUILDING_SUBLINE = BUILDING_SUBLINE; diff --git a/viewer/openworlds/index.html b/viewer/openworlds/index.html index 530d526f..56c11dcc 100644 --- a/viewer/openworlds/index.html +++ b/viewer/openworlds/index.html @@ -40,6 +40,7 @@ + diff --git a/viewer/openworlds/screen-create.jsx b/viewer/openworlds/screen-create.jsx index 51fd1f7f..fbe2c48e 100644 --- a/viewer/openworlds/screen-create.jsx +++ b/viewer/openworlds/screen-create.jsx @@ -134,6 +134,11 @@ function ScreenCreate({ onNavigate, state, setState }) { }; setSummonError(""); setSummoning(true); + // Show the full-screen "building your universe" loading experience the instant Bind is + // pressed — it persists through the mint, the reload, and the cold-open, and hands off to the + // table when the first narration lands. kind:"forge" tunes the overlay's eyebrow to the + // hero-binding flow. (building-universe.jsx; App reads it via useBuildingUniverse.) + window.OpenWorldsBuilding?.begin?.({ world: "baldurs-gate", kind: "forge", title: spec.name }); const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, ""); try { const reply = await window.OpenWorldsNative.request("startProviderSession", { @@ -151,9 +156,13 @@ function ScreenCreate({ onNavigate, state, setState }) { window.location.assign(liveUrl); return; } + window.OpenWorldsBuilding?.clear?.(); setSummoning(false); setSummonError("The hero was bound, but the live viewer address was missing."); } catch (error) { + // Bind failed before any reload — tear the loading overlay back down so the player isn't + // stranded on it, and surface the error here on the wizard. + window.OpenWorldsBuilding?.clear?.(); setSummoning(false); setSummonError(error?.message || String(error)); toast({ diff --git a/viewer/openworlds/screen-launcher.jsx b/viewer/openworlds/screen-launcher.jsx index b54ab774..5d1e3a77 100644 --- a/viewer/openworlds/screen-launcher.jsx +++ b/viewer/openworlds/screen-launcher.jsx @@ -42,6 +42,12 @@ function ScreenLauncher({ onNavigate, state, setState }) { } setSummonError(""); setSummoning(true); + // Show the full-screen "building your universe" loading experience THE INSTANT play is + // pressed — before the async mint and the reload it triggers. The flag is stamped into + // sessionStorage so it survives the location.assign reload below and keeps covering the + // cold-open on the live viewer, handing off to the table when the first narration lands. + // (building-universe.jsx; App reads it via useBuildingUniverse.) + window.OpenWorldsBuilding?.begin?.({ world: world || "baldurs-gate", kind: "play" }); const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, ""); try { const reply = await window.OpenWorldsNative.request("startProviderSession", { @@ -59,9 +65,13 @@ function ScreenLauncher({ onNavigate, state, setState }) { window.location.assign(liveUrl); return; } + window.OpenWorldsBuilding?.clear?.(); setSummoning(false); setSummonError("The session started, but its live viewer address was missing."); } catch (error) { + // Mint failed before any reload — tear the loading overlay back down so the player isn't + // stranded on it, and surface the error here on the launcher. + window.OpenWorldsBuilding?.clear?.(); setSummoning(false); setSummonError(error?.message || String(error)); toast({ diff --git a/viewer/openworlds/styles.css b/viewer/openworlds/styles.css index 7d89b08a..b17ff455 100644 --- a/viewer/openworlds/styles.css +++ b/viewer/openworlds/styles.css @@ -954,3 +954,213 @@ a { color: inherit; text-decoration: none; cursor: pointer; } font-family: var(--f-display) !important; } .tw-panel * { font-family: inherit; } + +/* ========================================================================= + BUILDING YOUR UNIVERSE — full-screen loading experience (building-universe.jsx). + Covers BOTH play-start waits: the startProviderSession mint + the location.assign + reload (the sessionStorage flag survives it) AND the cold-open generation; hands off + to the live table the instant the first DM narration lands. On-brand parchment/brass, + animated (a forging brass seal + an HONEST indeterminate sweep — never a faked %), with + a live elapsed readout. Motion is stilled under reduced-motion / prefers-reduced-motion. + ========================================================================= */ +.building-universe { + position: fixed; + inset: 0; + z-index: 9000; /* above the window frame, the tweaks panel, toasts */ + display: grid; + place-items: center; + padding: 40px; + overflow: hidden; + color: var(--ink-800); + /* Warm parchment wash so the loading state IS the world's material, not a neutral spinner. */ + background: + radial-gradient(ellipse 70% 55% at 50% 38%, rgba(244, 210, 123, 0.18), transparent 70%), + radial-gradient(ellipse 120% 100% at 50% 50%, transparent 55%, rgba(40, 25, 10, 0.55) 100%), + linear-gradient(180deg, var(--p-100) 0%, var(--p-300) 55%, var(--p-400) 100%); + animation: bu-fade-in 420ms ease both; +} +/* A faint inked frame so the overlay reads as a deliberate plate, not a raw fill. */ +.building-universe::before { + content: ""; + position: absolute; + inset: 14px; + pointer-events: none; + box-shadow: + inset 0 0 0 1px var(--b-500), + inset 0 0 0 4px rgba(246, 236, 210, 0.5), + inset 0 0 0 5px var(--b-400); + opacity: 0.7; +} + +.building-universe .bu-stage { + position: relative; + z-index: 1; + width: min(560px, 92vw); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +/* ---- the forging brass seal ---- */ +.building-universe .bu-seal { + position: relative; + width: 124px; + height: 124px; + display: grid; + place-items: center; + margin-bottom: 26px; +} +.building-universe .bu-candleglow { + position: absolute; + inset: -40px; + pointer-events: none; + border-radius: 50%; + background: radial-gradient(circle, rgba(244, 210, 123, 0.5), transparent 62%); + mix-blend-mode: screen; + animation: flicker 4s ease-in-out infinite; +} +.building-universe .bu-seal svg { position: relative; z-index: 1; overflow: visible; } +.building-universe .bu-ring { transform-origin: 60px 60px; } +.building-universe .bu-ring-outer { animation: bu-spin 14s linear infinite; } +.building-universe .bu-ring-inner { animation: bu-spin 9s linear infinite reverse; } +.building-universe .bu-core { transform-origin: 60px 60px; animation: bu-breathe 3.2s ease-in-out infinite; } + +.building-universe .bu-eyebrow { + font-family: var(--f-display); + font-size: 12px; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--crimson); + margin-bottom: 12px; +} +.building-universe .bu-headline { + font-family: var(--f-display); + font-size: 30px; + line-height: 1.18; + letter-spacing: 0.02em; + color: var(--ink-900); + margin: 0 0 12px; + min-height: 1.18em; /* hold height as the rotating line changes — no layout jump */ + text-wrap: balance; +} +.building-universe .bu-subline { + font-family: var(--f-hand); + font-size: 17px; + line-height: 1.5; + color: var(--ink-700); + margin: 0 0 26px; + max-width: 46ch; + min-height: 2.6em; +} + +/* ---- the HONEST indeterminate sweep (an etched channel; loops, never claims a %) ---- */ +.building-universe .bu-sweep { + position: relative; + width: min(380px, 80vw); + height: 6px; + background: rgba(120, 90, 50, 0.18); + box-shadow: inset 0 0 0 1px var(--b-500), inset 0 1px 2px rgba(80, 50, 20, 0.3); + overflow: hidden; +} +.building-universe .bu-sweep-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 38%; + background: linear-gradient(90deg, transparent, var(--b-300), var(--gold-glow), var(--b-300), transparent); + animation: bu-sweep 1900ms ease-in-out infinite; +} + +.building-universe .bu-meta { + margin-top: 16px; + display: flex; + align-items: center; + gap: 12px; +} +.building-universe .bu-dots { display: inline-flex; gap: 5px; } +.building-universe .bu-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--b-400); + animation: bu-dot 1200ms ease-in-out infinite; +} +.building-universe .bu-dots span:nth-child(2) { animation-delay: 200ms; } +.building-universe .bu-dots span:nth-child(3) { animation-delay: 400ms; } +.building-universe .bu-elapsed { + font-family: var(--f-mono); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--ink-600); + font-variant-numeric: tabular-nums; +} + +/* The brief "Your story begins…" flourish as the first beat lands and we hand off to the table. */ +.building-universe.is-handoff { animation: bu-fade-in 320ms ease both; } +.building-universe.is-handoff .bu-headline { color: var(--crimson); } + +/* Visually-hidden but in the a11y tree — the once-announced role="status" reassurance. */ +.building-universe .visually-hidden { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +@keyframes bu-fade-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes bu-spin { to { transform: rotate(360deg); } } +@keyframes bu-breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } } +@keyframes bu-dot { 0%, 80%, 100% { opacity: 0.25; } 40% { opacity: 1; } } +/* The sweep travels the channel and back-eases — clearly "ongoing work", not a measured fill. */ +@keyframes bu-sweep { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(370%); } +} + +/* Narrow windows: scale the centerpiece down so it never overflows the frame. */ +@media (max-width: 720px) { + .building-universe .bu-headline { font-size: 24px; } + .building-universe .bu-seal { width: 100px; height: 100px; } +} + +/* Reduced motion (Settings opt-in OR OS preference): kill the spin/sweep/flicker/breathe but + KEEP the elapsed clock + rotating text (they're information, not decoration — they still answer + "is it alive?"). The sweep-fill is parked centered at a steady glow so the channel isn't empty. */ +[data-reduced-motion="on"] .building-universe .bu-ring, +[data-reduced-motion="on"] .building-universe .bu-core, +[data-reduced-motion="on"] .building-universe .bu-candleglow, +[data-reduced-motion="on"] .building-universe .bu-dots span, +[data-reduced-motion="on"] .building-universe .bu-sweep-fill, +[data-reduced-motion="on"] .building-universe.is-handoff, +[data-reduced-motion="on"] .building-universe { + animation: none !important; +} +/* The travelling sweep is purely decorative; under reduced motion we hide it entirely (rather + than park a half-animated transform) and show a steady inked rule in its place — the proof-of- + life is carried by the elapsed clock + the rotating text, which remain. */ +[data-reduced-motion="on"] .building-universe .bu-sweep { background: rgba(120, 90, 50, 0.3); } +[data-reduced-motion="on"] .building-universe .bu-sweep-fill { display: none !important; } +[data-reduced-motion="on"] .building-universe .bu-dots span { opacity: 0.7; } +@media (prefers-reduced-motion: reduce) { + .building-universe .bu-ring, + .building-universe .bu-core, + .building-universe .bu-candleglow, + .building-universe .bu-dots span, + .building-universe { animation: none !important; } + .building-universe .bu-sweep { background: rgba(120, 90, 50, 0.3); } + .building-universe .bu-sweep-fill { display: none !important; } + .building-universe .bu-dots span { opacity: 0.7; } +} + +/* High-contrast: drop the decorative washes, strengthen text + the seal/sweep edges. */ +[data-contrast="high"] .building-universe { + background: var(--p-100); +} +[data-contrast="high"] .building-universe::before { box-shadow: inset 0 0 0 2px var(--ink-800); } +[data-contrast="high"] .building-universe .bu-candleglow { display: none; } +[data-contrast="high"] .building-universe .bu-sweep { box-shadow: inset 0 0 0 2px var(--ink-800); } diff --git a/viewer/tests/test_building_universe.py b/viewer/tests/test_building_universe.py new file mode 100644 index 00000000..be827694 --- /dev/null +++ b/viewer/tests/test_building_universe.py @@ -0,0 +1,278 @@ +"""Behavior tests for the "Building your universe" full-screen loading experience. + +Pressing Start / Resume → Play (screen-launcher.jsx) or Bind (screen-create.jsx) mints a DM +provider session and then generates the cold-open — two long waits with a full page RELOAD +(window.location.assign) wedged between them. Before this feature the player saw "nothing +happens" then an abrupt read-only flash. building-universe.jsx replaces both waits with one +intentional, on-brand loading state that: + • appears the instant play is pressed (window.OpenWorldsBuilding.begin), + • SURVIVES the reload (the intent is stamped in sessionStorage, not React state), and + • hands off to the live table when the FIRST DM narration beat lands in + liveSession.chatBeats (the same real milestone the in-table cold-open clears on). + +The bar of honesty: rotating lore flavor + a live elapsed readout + an indeterminate sweep — +NO faked percentage. These tests assert exactly those contracts against the REAL component by +transpiling the actual building-universe.jsx with the SAME bundled Babel-standalone the browser +uses and rendering BuildingUniverse / driving OpenWorldsBuilding under a tiny stub — so the test +tracks the shipped JSX, not a reimplementation (mirrors test_cold_open_progress.py). +""" + +import json +import shutil +import subprocess +import unittest +from pathlib import Path + + +_OPENWORLDS = Path(__file__).resolve().parents[1] / "openworlds" +_BUILDING = _OPENWORLDS / "building-universe.jsx" +_BABEL = _OPENWORLDS / "vendor" / "babel-standalone-7.29.0.min.js" + + +# A self-contained Node harness. React.createElement is captured into a plain node tree; hooks are +# stubbed (useState seeds its initial value, useEffect/useRef/useCallback are inert) so we render +# BuildingUniverse at a chosen elapsed time. A tiny in-memory sessionStorage + dispatchEvent lets us +# exercise the OpenWorldsBuilding persistence facade (begin / read / clear) the way the page does. +_HARNESS = r""" +const fs = require('fs'); +const vm = require('vm'); +const Babel = require(%(babel)s); + +let NOW = 1000000; + +function makeReact() { + function useState(init) { const v = (typeof init === 'function') ? init() : init; return [v, function () {}]; } + function useEffect() {} + function useRef(init) { return { current: init }; } + function useCallback(fn) { return fn; } + function createElement(type, props) { + const children = Array.prototype.slice.call(arguments, 2); + return { type, props: props || {}, children }; + } + return { useState, useEffect, useRef, useCallback, createElement, Fragment: 'F' }; +} + +const React = makeReact(); +// In-memory sessionStorage so OpenWorldsBuilding.{begin,read,clear} are exercised for real. +const _store = {}; +const sessionStorage = { + getItem: (k) => (k in _store ? _store[k] : null), + setItem: (k, v) => { _store[k] = String(v); }, + removeItem: (k) => { delete _store[k]; }, +}; +const _events = {}; +const sandbox = { + React, + ReactDOM: { createRoot: () => ({ render() {} }) }, + document: { addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', getElementById: () => ({}), head: { appendChild() {} }, createElement: () => ({}) }, + sessionStorage, + CustomEvent: function (type, opts) { this.type = type; this.detail = (opts || {}).detail; }, + setInterval: () => 0, clearInterval: () => {}, setTimeout: () => 0, clearTimeout: () => {}, + console, +}; +sandbox.window = sandbox; +sandbox.window.addEventListener = (t, fn) => { (_events[t] = _events[t] || []).push(fn); }; +sandbox.window.removeEventListener = () => {}; +const _dispatched = []; +sandbox.window.dispatchEvent = (e) => { _dispatched.push(e.type); (_events[e.type] || []).forEach((fn) => fn(e)); return true; }; +sandbox.Date = { now: () => NOW }; +vm.createContext(sandbox); + +function load(p) { + const src = fs.readFileSync(p, 'utf8'); + const code = Babel.transform(src, { presets: ['react'], filename: p }).code; + vm.runInContext(code, sandbox); +} +load(%(building)s); + +const BuildingUniverse = sandbox.window.BuildingUniverse; +if (typeof BuildingUniverse !== 'function') throw new Error('BuildingUniverse not exported'); + +function collectText(node, accessibleOnly, hiddenAncestor) { + let out = []; + if (node == null || node === false) return out; + if (typeof node === 'string' || typeof node === 'number') { + if (!(accessibleOnly && hiddenAncestor)) out.push(String(node)); + return out; + } + if (Array.isArray(node)) { for (const c of node) out = out.concat(collectText(c, accessibleOnly, hiddenAncestor)); return out; } + const props = node.props || {}; + const hidden = hiddenAncestor || props['aria-hidden'] === 'true' || props['aria-hidden'] === true; + const kids = (node.children && node.children.length ? node.children : (props.children !== undefined ? [props.children] : [])); + for (const c of kids) out = out.concat(collectText(c, accessibleOnly, hidden)); + return out; +} +function statusText(node, inStatus) { + let out = []; + if (node == null || typeof node !== 'object') return out; + if (Array.isArray(node)) { for (const c of node) out = out.concat(statusText(c, inStatus)); return out; } + const props = node.props || {}; + const here = inStatus || props.role === 'status'; + const kids = (node.children && node.children.length ? node.children : (props.children !== undefined ? [props.children] : [])); + for (const c of kids) { + if (typeof c === 'string' || typeof c === 'number') { if (here) out.push(String(c)); } + else out = out.concat(statusText(c, here)); + } + return out; +} +function countStatus(node) { + let n = 0; + if (node == null || typeof node !== 'object') return 0; + if (Array.isArray(node)) { for (const c of node) n += countStatus(c); return n; } + const props = node.props || {}; + if (props.role === 'status') n += 1; + const kids = (node.children && node.children.length ? node.children : (props.children !== undefined ? [props.children] : [])); + for (const c of kids) n += countStatus(c); + return n; +} + +function report(props) { + const tree = BuildingUniverse(props); + return { + allText: collectText(tree, false, false).join(' ␟ '), + accessibleText: collectText(tree, true, false).join(' ␟ '), + statusRegions: countStatus(tree), + statusText: statusText(tree, false).join(' '), + }; +} + +const B = sandbox.window.OpenWorldsBuilding; +const h = { + setNow: (n) => { NOW = n; }, + render: (elapsedSec, opts) => report(Object.assign({ record: { startedAt: NOW - elapsedSec * 1000, kind: (opts && opts.kind) || 'play' } }, opts || {})), + flavor: () => sandbox.window.BUILDING_FLAVOR, + // persistence facade + begin: (meta) => B.begin(meta), + read: () => B.read(), + clear: () => B.clear(), + rawStore: () => Object.assign({}, _store), + backstopMs: () => B.backstopMs, + beginEventsFired: () => _dispatched.filter((t) => t === 'clawdnd:building-begin').length, + // write a raw record directly (the script string runs in the OUTER node scope where `window` + // is not global — go through the sandbox's storage via this helper instead). + seedRaw: (k, v) => { sessionStorage.setItem(k, v); }, +}; + +const script = %(script)s; +const result = (function () { return eval(script); })(); +process.stdout.write(JSON.stringify(result)); +""" + + +@unittest.skipIf(shutil.which("node") is None, "node is required to transpile + render the JSX") +class BuildingUniverseTests(unittest.TestCase): + NODE_BIN = shutil.which("node") + + @classmethod + def setUpClass(cls): + for p in (_BUILDING, _BABEL): + assert p.exists(), f"missing {p}" + + def _run(self, script: str): + program = _HARNESS % { + "babel": json.dumps(str(_BABEL)), + "building": json.dumps(str(_BUILDING)), + "script": json.dumps(script), + } + proc = subprocess.run( + [self.NODE_BIN, "--input-type=commonjs"], + input=program, + text=True, + capture_output=True, + ) + if proc.returncode != 0: + self.fail(f"node harness failed:\nSTDOUT:{proc.stdout}\nSTDERR:{proc.stderr}") + return json.loads(proc.stdout) + + # --- persistence facade: begin() stamps sessionStorage AND announces ----------------------- + def test_begin_persists_to_session_storage_and_is_readable(self): + out = self._run( + "(function(){ h.begin({world:'baldurs-gate', kind:'play'});" + " var rec = h.read();" + " return { hasKey: ('openworlds.building' in h.rawStore()), startedAt: typeof rec.startedAt," + " world: rec.world, kind: rec.kind, announced: h.beginEventsFired() }; })()" + ) + self.assertTrue(out["hasKey"], "begin() must stamp the intent into sessionStorage (survives the reload)") + self.assertEqual(out["startedAt"], "number") + self.assertEqual(out["world"], "baldurs-gate") + self.assertEqual(out["kind"], "play") + self.assertGreaterEqual(out["announced"], 1, "begin() must dispatch clawdnd:building-begin so a mounted App shows it pre-reload") + + def test_clear_removes_the_flag(self): + out = self._run( + "(function(){ h.begin({world:'x'}); var before = ('openworlds.building' in h.rawStore());" + " h.clear(); var after = ('openworlds.building' in h.rawStore());" + " return { before: before, after: after, readNull: (h.read()===null) }; })()" + ) + self.assertTrue(out["before"]) + self.assertFalse(out["after"], "clear() must remove the persisted flag") + self.assertTrue(out["readNull"], "read() must return null after clear()") + + def test_read_self_heals_a_stale_record_past_the_backstop(self): + # A record older than the 12-min backstop is stale → read() drops it so a fresh load + # doesn't re-enter the overlay forever. + out = self._run( + "(function(){ var ms = h.backstopMs();" + " h.setNow(50000000);" + " h.seedRaw('openworlds.building', JSON.stringify({startedAt: 50000000 - ms - 1000}));" + " var r = h.read(); return { readNull: (r===null), cleared: !('openworlds.building' in h.rawStore()) }; })()" + ) + self.assertTrue(out["readNull"], "a record older than the backstop must read as null") + self.assertTrue(out["cleared"], "the stale record must be cleared from storage") + + # --- the overlay is honest: NO faked percentage -------------------------------------------- + def test_overlay_has_no_fake_percentage(self): + out = self._run("h.render(20, {})") + self.assertNotIn("%", out["allText"], "the loading copy must not show a fake percentage — honesty bar") + + # --- #385 lesson: the accessible text CHANGES over time (not a frozen app) ------------------ + def test_overlay_accessible_text_changes_over_time(self): + out = self._run( + "({ early: h.render(1, {}), mid: h.render(30, {}), late: h.render(120, {}) })" + ) + self.assertNotEqual(out["early"]["accessibleText"], out["mid"]["accessibleText"], + "the loading text must change as time passes (not frozen)") + self.assertNotEqual(out["mid"]["accessibleText"], out["late"]["accessibleText"], + "the loading text must keep changing deeper into the wait") + # the live elapsed readout is part of the ACCESSIBLE text (proof-of-life on a screenshot AND aria) + self.assertIn("0:01", out["early"]["allText"]) + self.assertIn("0:30", out["mid"]["allText"]) + self.assertIn("2:00", out["late"]["allText"]) + + # --- the headline rotates through the lore flavor pool ------------------------------------- + def test_headline_rotates_through_flavor(self): + flavor = self._run("h.flavor()") + self.assertGreaterEqual(len(flavor), 4) + out = self._run("[0,4,8,14,22,30].map(function(s){ return h.render(s, {}).allText; })") + seen = set() + for txt in out: + for line in flavor: + if line in txt: + seen.add(line) + self.assertGreaterEqual(len(seen), 3, "the headline should rotate through several lore lines") + + # --- the announced live region is STABLE (no per-second screen-reader spam) ----------------- + def test_live_region_is_stable_not_per_second(self): + out = self._run("({ a: h.render(7, {}), b: h.render(8, {}) })") + self.assertGreaterEqual(out["a"]["statusRegions"], 1, "the wait must be announced via a status region") + self.assertEqual(out["a"]["statusText"], out["b"]["statusText"], + "the announced text must be stable second-to-second (no per-tick spam)") + import re as _re + self.assertIsNone(_re.search(r"\d:\d\d", out["a"]["statusText"]), + "the announced live-region text must not carry a ticking clock") + + # --- the handoff flourish reads as 'your story begins' -------------------------------------- + def test_handoff_phase_shows_story_begins(self): + out = self._run("h.render(40, { handoff: true })") + self.assertIn("begins", out["allText"].lower(), "the handoff flourish should read as the story beginning") + + # --- the forge entry tunes the eyebrow ------------------------------------------------------ + def test_forge_kind_changes_eyebrow(self): + play = self._run("h.render(5, { record: { startedAt: 1000000 - 5000, kind: 'play' } })") + forge = self._run("h.render(5, { record: { startedAt: 1000000 - 5000, kind: 'forge' } })") + self.assertIn("universe", play["allText"].lower()) + self.assertIn("hero", forge["allText"].lower(), "the forge flow should name the hero binding") + + +if __name__ == "__main__": + unittest.main()