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. */}
+