From 760b94343b9d5869babdbdc96498ba14265b7723 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 31 May 2026 00:41:54 +0700 Subject: [PATCH] fix(viewer): wire the building-universe overlay escape hatch so it can never wedge (#405) + streaming polish (#406) #405 (CRITICAL): the "building your universe" cover (z-9000) only cleared via the first-narration handoff or a 12-min backstop; its dismiss() callback had ZERO call sites. On a stalled/errored cold-open it wedged full-screen over the table's own recovery for up to 12 minutes. The overlay is only a COVER for the slow cold-open, so it now YIELDS to the table (live streaming + 180s narrating timeout + "Try again") on ANY of: a hard cold-open/session error (dismiss after a 3s stale-error grace), a FIXED ~120s stall ceiling (not the 12-min wall, not re-armed per beat), or a manual "Enter anyway ->" button surfaced after ~15s. dismiss() is wired in App (onEnterAnyway) and passed the bridge error. After dismiss the table is reachable + usable (cold-open action bar is enabled). Found + fixed a latent bug the new tests surfaced: the handoff flourish armed its 1400ms dismiss timer in the SAME effect that flipped `handoff`, whose cleanup cancelled the timer on the re-run -> the overlay never auto-dismissed on first narration. Split into flip + a separate handoff-keyed arm effect. #406 polish: 1. aria-modal: dropped the false role=dialog/aria-modal (no focus trap) -> aria-busy labeled container + the existing role=status announcement. 2. backstop re-arm: notePendingProgress now re-arms only the 'stuck' recovery timer, not the absolute backstop (armed once in armPending) -> a streaming-but-unresolved turn can't defer the 12-min cap forever. 3. dedup scope: #407 fixed the canonical seq-keyed path; a residual TEXT-key suppression remained for the /chat-only fallback across turns -> seenText now resets per turn (a repeated short line on a later turn renders again). 4. retried cold-open window: firstBeat now keys off resolvedTurnsRef (bumped only on /chat resolution), not streamed paragraphs -> a retried cold-open keeps the 4-min window instead of dropping to 180s. 5. headline freeze: added BUILDING_FLAVOR_LATE (calmer pool past ~42s) so the headline keeps fresh, on-arc copy for the full overlay lifetime. 6. untested lifecycle: added an effect-running harness + lifecycle tests (handoff, dismiss-on-error+grace, dismiss-on-ceiling, manual escape, table-reachable, negative disclosure) and 2 streaming-hook regression guards; rewrote the misleading min-display-floor comment. --- viewer/openworlds/app.jsx | 74 +++- viewer/openworlds/building-universe.jsx | 217 +++++++++--- viewer/openworlds/styles.css | 31 ++ viewer/tests/test_building_universe.py | 394 +++++++++++++++++++++ viewer/tests/test_live_narration_stream.py | 52 +++ 5 files changed, 713 insertions(+), 55 deletions(-) diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 3024f522..0d6955c6 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -161,6 +161,14 @@ function useLiveSession(state) { const chatCursor = React.useRef(0); const eventsCursor = React.useRef(0); // #393: per-file cursor for the live /events tail const dmBeatCountRef = React.useRef(0); + // #406: the count of turns that have RESOLVED on /chat (the turn-END signal), bumped ONLY in the + // /chat poll — NOT by streamed /events paragraphs. `firstBeat` (the generous cold-open recovery + // window) keys off THIS, not dmBeatCountRef: the cold-open is still "the first beat" until its + // turn actually resolves, so a player who hits "Try again" after one paragraph streamed keeps the + // 4-min window instead of being dropped to the 180s later-beat window (the #348 false-stuck trap + // re-introduced for the still-opening cold open). dmBeatCountRef stays the per-paragraph counter + // (it gates streaming dedup/order), but it no longer decides the recovery window. + const resolvedTurnsRef = React.useRef(0); // #393/#405: dedup key sets across BOTH narration sources. There are TWO key spaces and the // distinction is the whole fix: // • `seenSeq` — the STABLE session-log line index (`seq`) the server now stamps on every /events @@ -176,6 +184,10 @@ function useLiveSession(state) { // its turn-END reply — or /chat carrying the whole beat as one blob while /events carried N // paragraphs — defeated the dedup and the chronicle showed each beat 3-4× and out of order. const seenSeq = React.useRef(new Set()); + // #406: seenText is RESET per turn (when a /chat DM line resolves a turn, below) — it only needs to + // span one turn's /events→/chat gap, so a run-long text set is wrong: it would permanently suppress + // a legitimately-repeated short line (a catchphrase, a repeated "Yes.") on a /chat-only path. seenSeq + // stays run-long (stable ids never collide and must absorb re-ingests). const seenText = React.useRef(new Set()); // #405: did the CURRENT in-flight turn stream any narration via the canonical /events source? When // true, the turn-END /chat DM line is a pure turn-RESOLUTION signal (it clears the pending @@ -216,10 +228,16 @@ function useLiveSession(state) { return true; }, []); - const clearTimers = React.useCallback(() => { + // Clear ONLY the adaptive 'stuck' recovery timer (the one notePendingProgress re-arms each + // streamed beat). Kept separate from the absolute backstop so a streaming turn can reset 'stuck' + // WITHOUT pushing the hard 12-min cap forward (see notePendingProgress / #406). + const clearRecoveryTimer = React.useCallback(() => { if (recoveryTimer.current) { window.clearTimeout(recoveryTimer.current); recoveryTimer.current = null; } - if (backstopTimer.current) { window.clearTimeout(backstopTimer.current); backstopTimer.current = null; } }, []); + const clearTimers = React.useCallback(() => { + clearRecoveryTimer(); + if (backstopTimer.current) { window.clearTimeout(backstopTimer.current); backstopTimer.current = null; } + }, [clearRecoveryTimer]); // #393: a ref mirror of `pending` so a poll callback (whose effect deps deliberately EXCLUDE // `pending`, to avoid re-subscribing the 3s interval every turn) can read the CURRENT turn state @@ -247,7 +265,10 @@ function useLiveSession(state) { // real narration beat regardless of this flag). const armPending = React.useCallback((text) => { clearTimers(); - const firstBeat = dmBeatCountRef.current === 0; + // #406: "first beat?" = no turn has RESOLVED on /chat yet (resolvedTurnsRef), NOT "no paragraph + // has streamed" (dmBeatCountRef). So a retried cold-open — one paragraph streamed, then "Try + // again" before the turn resolved — still gets the generous PENDING_RECOVERY_FIRST_MS window. + const firstBeat = resolvedTurnsRef.current === 0; const recoveryMs = recoveryWindowMs(firstBeat); setPendingState({ text, since: Date.now(), stuck: false, firstBeat }); recoveryTimer.current = window.setTimeout(() => { @@ -268,15 +289,20 @@ function useLiveSession(state) { // StrictMode's double-invoked updaters. const p = pendingRef.current; if (!p) return; - clearTimers(); + // #406: re-arm ONLY the adaptive 'stuck' recovery timer — NOT the absolute backstop. The + // backstop is a hard wall-clock cap from submit (armed once in armPending); re-arming it on + // every streamed beat let a turn that streams a paragraph every few seconds but never resolves + // on /chat defer the 12-min cap FOREVER (so neither 'stuck' nor the backstop ever fired). Now a + // long-but-healthy streaming turn keeps resetting 'stuck' (it's plainly alive) while the + // absolute cap still fires at its original deadline. + clearRecoveryTimer(); const recoveryMs = recoveryWindowMs(Boolean(p.firstBeat)); recoveryTimer.current = window.setTimeout(() => { setPendingState((q) => (q ? { ...q, stuck: true } : q)); }, recoveryMs); - backstopTimer.current = window.setTimeout(() => setPendingState(null), PENDING_BACKSTOP_MS); // Clear any prior 'stuck' flag — fresh prose just arrived, so the turn is plainly not stuck. if (p.stuck) setPendingState((q) => (q ? { ...q, stuck: false } : q)); - }, [clearTimers, setPendingState]); + }, [clearRecoveryTimer, setPendingState]); // #399: idempotent player echo. The #344 'Try again' recovery re-POSTs the EXACT stalled move // (postMove → recordPlayerEcho again), which used to append a SECOND identical action row — the @@ -305,6 +331,7 @@ function useLiveSession(state) { chatCursor.current = 0; eventsCursor.current = 0; // #393: reset the live /events tail per run dmBeatCountRef.current = 0; + resolvedTurnsRef.current = 0; // #406: a fresh run has resolved no turns yet (cold-open window) seenSeq.current = new Set(); // #405: a fresh run shares no seq dedup keys with the last seenText.current = new Set(); // #405: …nor any text-key fallback keys eventsStreamedThisTurnRef.current = false; // #405: no /events narration streamed for any turn yet @@ -364,11 +391,24 @@ function useLiveSession(state) { // Player echoes alone never resolve a turn. if (dmLineArrived) { dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length; + // #406: a /chat DM line is the turn-RESOLUTION signal — count it so the NEXT turn is no + // longer treated as the cold-open 'firstBeat'. This is the ONLY place the count bumps + // (the /events stream bumps dmBeatCountRef, not this), so a retried cold-open whose first + // turn never resolved keeps its generous recovery window. + resolvedTurnsRef.current += 1; clearPending(); // #405: the turn is over → reset the per-turn "/events streamed" flag so the NEXT turn is // judged on ITS OWN streaming. Without this, a streamed turn would wrongly suppress a // later TERSE turn's /chat-only prose (the flag would stay stuck true for the whole run). eventsStreamedThisTurnRef.current = false; + // #406: scope the TEXT-key dedup to the turn (the seq-keyed /events path is the canonical, + // run-long dedup; #407). seenText only needs to span ONE turn's /events→/chat gap, so a + // run-long text set would PERMANENTLY suppress a legitimately-repeated short line on a + // /chat-only path (an NPC catchphrase, a repeated "Yes." / "The door is locked." on a + // later turn) — the turn resolves but shows no new prose ("the DM said nothing"). Reset + // it once the turn resolves so the next turn's identical line renders. (seenSeq is NOT + // reset — stable ids never collide, and it must stay run-long to absorb re-ingests.) + seenText.current = new Set(); } } if (!cancelled && typeof payload.next === "number") chatCursor.current = payload.next; @@ -504,9 +544,16 @@ function App() { // 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. + // #405: a cold-open / session error reported on the native bridge (appStatus.lastError, mirrored + // into nativeState.error) means no narration will ever arrive — feed it to the hook so the overlay + // DISMISSES immediately (yielding to the table, which surfaces the error + a retry) rather than + // wedging a full-screen cover over the recovery. Pre-RELOAD mint failures are already torn down by + // screen-launcher / screen-create (they call OpenWorldsBuilding.clear()); this catches the + // POST-reload cold-open error, where the overlay is up and only the bridge status can flag it. + const coldOpenError = (nativeState && (nativeState.appStatus?.lastError || nativeState.error)) || ""; const building = (typeof window.useBuildingUniverse === "function") - ? window.useBuildingUniverse(liveSession) - : { active: false, record: null, handoff: false, dismiss: () => {} }; + ? window.useBuildingUniverse(liveSession, coldOpenError) + : { active: false, record: null, handoff: false, escapable: false, dismiss: () => {} }; React.useEffect(() => { document.documentElement.setAttribute("data-palette", t.palette || "warm"); @@ -796,9 +843,16 @@ function App() { {/* 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. */} + the app-level /chat poll keeps running. Clears itself when the first DM narration lands — + or, #405, on a cold-open error, a ~120s stall ceiling, or the manual "Enter anyway →" + (onEnterAnyway → dismiss), so it can never wedge over the table's own recovery. */} {building.active && window.BuildingUniverse && ( - + )} ); diff --git a/viewer/openworlds/building-universe.jsx b/viewer/openworlds/building-universe.jsx index 5d1a252b..24c74561 100644 --- a/viewer/openworlds/building-universe.jsx +++ b/viewer/openworlds/building-universe.jsx @@ -25,8 +25,18 @@ * 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. + * + * HOW IT CAN NEVER WEDGE (#405). The overlay is only a COVER for the slow cold-open — it must YIELD + * to the table (which has the real recovery: live streaming narration, a 180s "DM is narrating…" + * timeout, and a "Try again" affordance). So dismiss() — wired in App — fires on ANY of three exits, + * not just the handoff: (1) a HARD ERROR (a cold-open / session error reported on the native bridge) + * dismisses immediately; (2) a ~120s STALL CEILING (a FIXED deadline from the build's start, NOT a + * 12-min one and NOT re-armed per beat) dismisses so the table's own handling takes over; and (3) a + * manual "Enter anyway →" affordance (after ~15s) lets an impatient player skip to the table anytime. + * After any dismiss the table is fully reachable (the z-9000 cover is unmounted) and usable — on a + * cold-open the action bar is enabled, so the player can act and the table's own narrating/stuck- + * recovery engages. The 12-min sessionStorage self-heal in read() is a SEPARATE net for the reload + * path only (a stale flag can't re-enter the overlay), not the live overlay's ceiling. * * 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. @@ -37,11 +47,34 @@ // 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) +// #405: the LIVE dismiss ceiling. The overlay is only a COVER for the legitimately-slow cold-open +// (the engine spends ~55s building the world + composing the opening). It is NOT the recovery +// surface — the table is (live streaming narration, a 180s "DM is narrating…" timeout, and a +// "Try again" recovery). So if no first-narration handoff arrives within this ceiling we DISMISS +// the overlay and let the table's own handling take over, rather than wedging the player on a +// full-screen cover. ~120s comfortably covers the real cold-open + margin without the old +// 12-minute dead-end. This is a FIXED deadline measured from the build's startedAt — it does NOT +// re-arm on streamed beats (the overlay's handoff is the first-narration milestone, not "any beat"). +const OW_BUILDING_CEILING_MS = 120 * 1000; +// #405: a manual escape. After this long with no handoff, surface an "Enter anyway →" affordance so +// an impatient player can skip straight to the table at any time (the table is fully usable — its +// own narrating/stuck-recovery takes over). Well below the ceiling so the player is never trapped. +const OW_BUILDING_ESCAPE_MS = 15 * 1000; +// #405: a short grace before an ERROR signal is honored as a dismiss. The error comes from the +// native bridge's lastError, which can carry a STALE error from a PRIOR failed attempt at the +// instant a fresh build begins (the same-page launcher window, before the reload re-inits native +// state). Ignoring the error for the first few seconds of a build prevents a just-clicked Play from +// being nuked by a leftover error; a genuine cold-open failure is observed on a later bridge poll +// (well past this grace), so real errors still dismiss promptly. Far below the manual-escape time, +// so the player is never stuck waiting on it. +const OW_BUILDING_ERROR_GRACE_MS = 3 * 1000; +// The cross-RELOAD stale-flag net (NOT a min-display floor — no such floor exists; on a fast bridge +// failure both screen-launcher and screen-create call clear() immediately, so the overlay flashes +// away). A persisted record older than this is stale — e.g. a tab left on the overlay, then a fresh +// load — so read() drops it rather than re-entering the overlay. Kept generous (12 min) because it +// only guards the reload path; the LIVE overlay's lifetime is governed by the handoff + the ~120s +// ceiling above + the error/manual dismiss wired in App, none of which depend on this. +const OW_BUILDING_BACKSTOP_MS = 12 * 60 * 1000; window.OpenWorldsBuilding = window.OpenWorldsBuilding || { // Stamp the intent + announce it so a still-mounted App shows the overlay this tick (pre-reload). @@ -78,6 +111,9 @@ window.OpenWorldsBuilding = window.OpenWorldsBuilding || { try { window.sessionStorage.removeItem(OW_BUILDING_KEY); } catch (_e) {} }, backstopMs: OW_BUILDING_BACKSTOP_MS, + ceilingMs: OW_BUILDING_CEILING_MS, // #405: the live dismiss ceiling (yield to the table) + escapeMs: OW_BUILDING_ESCAPE_MS, // #405: when the "Enter anyway" affordance appears + errorGraceMs: OW_BUILDING_ERROR_GRACE_MS, // #405: grace before a (possibly stale) error dismisses }; // ---- rotating lore flavor (honest "the world is being assembled" cues) ----------------------- @@ -100,6 +136,22 @@ const BUILDING_FLAVOR = [ "The ink is still drying on your first page…", ]; +// #406 (5): the LATE-phase pool. The early pool plays through in ~40s, but a real cold-open can run +// well past that (a blind newbie run saw minutes). Rather than recycle the bright early lines — +// which reads as "stuck on a loop" — shift past ~42s to a calmer, patient register that owns the +// length of the wait honestly ("a rich opening is worth it"). Still rotates so renders/snapshots +// differ (the #385 proof-of-life lesson), and is intentionally a different length from the early +// pool so the two phases don't lock-step. +const BUILDING_FLAVOR_LATE = [ + "Still composing — a rich opening is worth the wait…", + "The Dungeon Master is weaving the finer details…", + "Setting the final pieces of your opening scene…", + "A great tale takes a moment longer to begin…", + "Almost there — the first page is nearly written…", + "Holding the curtain a beat longer, for a worthy entrance…", + "The world is taking shape around your hero…", +]; + // 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.", @@ -111,84 +163,138 @@ const BUILDING_SUBLINE = [ // 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) { +// in-table cold-open clears on. #405: it ALSO dismisses (yields to the table) on a hard cold-open +// error, on a ~120s stall ceiling, and via a manual "Enter anyway →" affordance — so the cover can +// never wedge full-screen over the table's own recovery. +// `sessionError` (optional): a truthy cold-open/session error reported on the native bridge. When +// set, the overlay dismisses immediately so the player isn't stranded on a full-screen cover while +// the table (which surfaces the error + a retry) sits unreachable beneath it. +// Returns { active, record, handoff, escapable, dismiss }. +function useBuildingUniverse(liveSession, sessionError) { 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); + // #405: once the build has been up past OW_BUILDING_ESCAPE_MS with no handoff, expose the manual + // "Enter anyway →" affordance. Drives only the affordance's visibility; dismiss() does the work. + const [escapable, setEscapable] = React.useState(false); const handoffTimer = React.useRef(null); - const backstopTimer = React.useRef(null); + const ceilingTimer = React.useRef(null); + const escapeTimer = React.useRef(null); + const errorTimer = React.useRef(null); + + // The one true exit. Clears the persisted flag + unmounts the overlay (App then routes to the + // table). Used by the handoff flourish, the error dismiss, the stall ceiling, AND the manual + // "Enter anyway" button — every path that reveals the table funnels through here. + const dismiss = React.useCallback(() => { + window.OpenWorldsBuilding.clear(); + setRecord(null); + setHandoff(false); + setEscapable(false); + }, []); // begin() fired on THIS page (no reload yet — the launcher/forge click) → show immediately. React.useEffect(() => { const onBegin = (e) => { setHandoff(false); + setEscapable(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). + // #405 (1) HARD ERROR → dismiss. A cold-open / session error means no narration will ever arrive; + // the table surfaces the error + a retry, so get off the cover rather than making the player wait + // out a ceiling on a doomed build. Honored only after a short grace from the build's start, so a + // STALE bridge error at the click instant can't nuke a just-begun build (see the grace const); + // a genuine cold-open failure is seen on a later poll, well past the grace, so it still dismisses. + React.useEffect(() => { + if (!record || handoff) return undefined; + if (!sessionError) return undefined; + const sinceStart = Date.now() - (record.startedAt || Date.now()); + if (sinceStart >= OW_BUILDING_ERROR_GRACE_MS) { dismiss(); return undefined; } + // Within the grace window — re-check once it elapses (the error may be a stale leftover that a + // fresh build will clear; if it's still set past the grace, dismiss then). + errorTimer.current = window.setTimeout(dismiss, OW_BUILDING_ERROR_GRACE_MS - sinceStart); + return () => { + if (errorTimer.current) { window.clearTimeout(errorTimer.current); errorTimer.current = null; } + }; + }, [record, handoff, sessionError, dismiss]); + + // #405 (2) STALL CEILING — a FIXED ~120s deadline from the build's start (NOT re-armed per beat, + // NOT the old 12-min wall). If no first-narration handoff lands within it, dismiss so the table's + // own cold-open/stuck recovery (its 180s "DM is narrating…" timeout + "Try again") takes over. + // The cover exists only to mask the slow cold-open; once that's plausibly overrun, the table is + // the better surface. Also arms the ~15s manual-escape affordance off the SAME fixed clock. 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); + const start = record.startedAt || Date.now(); + const ceilingRemaining = Math.max(0, OW_BUILDING_CEILING_MS - (Date.now() - start)); + const escapeRemaining = Math.max(0, OW_BUILDING_ESCAPE_MS - (Date.now() - start)); + ceilingTimer.current = window.setTimeout(dismiss, ceilingRemaining); + // Surface the manual escape once past the threshold (immediately if a reload already overran it). + if (escapeRemaining <= 0) setEscapable(true); + else escapeTimer.current = window.setTimeout(() => setEscapable(true), escapeRemaining); return () => { - if (backstopTimer.current) { window.clearTimeout(backstopTimer.current); backstopTimer.current = null; } + if (ceilingTimer.current) { window.clearTimeout(ceilingTimer.current); ceilingTimer.current = null; } + if (escapeTimer.current) { window.clearTimeout(escapeTimer.current); escapeTimer.current = null; } }; - }, [record, handoff]); + }, [record, handoff, dismiss]); // 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). + // chat tail (the exact signal the cold-open pending clears on). FLIP into the flourish phase — + // but do NOT arm the dismiss timer here: this effect's deps include `handoff`, so flipping it + // re-runs the effect and its cleanup, which would CANCEL a timer armed in the same run before it + // could fire (the latent bug the un-effect'd unit tests could never catch — #406). The dismiss + // timer is armed in a SEPARATE effect keyed on `handoff`, below, whose cleanup only fires on + // unmount/record-change — so the flourish reliably ends in a dismiss. 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; + if (!record || handoff) return; + if (!hasFirstNarration) return; setHandoff(true); + setEscapable(false); + }, [record, handoff, hasFirstNarration]); + + // Arm the 1400ms flourish→dismiss timer ONCE we're in the handoff phase. Separate effect so the + // timer outlives the render that set `handoff` (see above). Cleanup only on unmount/record swap. + React.useEffect(() => { + if (!handoff) return undefined; handoffTimer.current = window.setTimeout(() => { - window.OpenWorldsBuilding.clear(); - setRecord(null); - setHandoff(false); + dismiss(); }, 1400); return () => { if (handoffTimer.current) { window.clearTimeout(handoffTimer.current); handoffTimer.current = null; } }; - }, [record, handoff, hasFirstNarration]); + }, [handoff, dismiss]); React.useEffect(() => () => { if (handoffTimer.current) window.clearTimeout(handoffTimer.current); - if (backstopTimer.current) window.clearTimeout(backstopTimer.current); + if (ceilingTimer.current) window.clearTimeout(ceilingTimer.current); + if (escapeTimer.current) window.clearTimeout(escapeTimer.current); + if (errorTimer.current) window.clearTimeout(errorTimer.current); }, []); - const dismiss = React.useCallback(() => { - window.OpenWorldsBuilding.clear(); - setRecord(null); - setHandoff(false); - }, []); - - return { active: Boolean(record), record, handoff, dismiss }; + return { active: Boolean(record), record, handoff, escapable, 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 }) { +// elapsed readout, and rotating lore flavor. a11y (#406): this is a LOADING STATE, not a dialog — +// it does NOT trap focus, inert the app, or handle Escape, so it must NOT claim role="dialog" +// aria-modal (a false-modal that leaves the covered app in the tab order). We use role="status" + +// aria-busy on the root and a single stable polite announcement; 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 per tick. #405: when `escapable`, a real focusable "Enter anyway →" button lets a +// keyboard/screen-reader user leave the cover for the table at any time. +function BuildingUniverse({ record, handoff, escapable, onEnterAnyway }) { const start = (record && typeof record.startedAt === "number") ? record.startedAt : Date.now(); const [now, setNow] = React.useState(() => Date.now()); React.useEffect(() => { @@ -200,10 +306,15 @@ function BuildingUniverse({ record, handoff }) { 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. + // Rotate the headline + subline for the FULL overlay lifetime (#406 item 5). The early pool plays + // through ~40s; past that, switch to a calmer "still composing — a rich opening is worth the wait" + // register (BUILDING_FLAVOR_LATE) rather than recycling the bright early lines — so a long + // cold-open (a blind newbie run saw minutes) keeps copy that tracks the real arc of the wait + // instead of looping. Both phases still ROTATE so consecutive renders/snapshots differ. + const lateHeadlines = secs >= 42 ? BUILDING_FLAVOR_LATE : BUILDING_FLAVOR; const headline = handoff ? "Your story begins…" - : BUILDING_FLAVOR[Math.floor(secs / 3.5) % BUILDING_FLAVOR.length]; + : lateHeadlines[Math.floor(secs / 3.5) % lateHeadlines.length]; const subline = handoff ? "Stepping into the scene the Dungeon Master has set for you." : BUILDING_SUBLINE[Math.floor(secs / 9) % BUILDING_SUBLINE.length]; @@ -213,8 +324,12 @@ function BuildingUniverse({ record, handoff }) { : (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. */} +
+ {/* Announced ONCE via a dedicated stable role="status" region — stable text, so the polite + region does not re-fire every second. The ROOT is NOT role="status" (it wraps the ticking + elapsed/headline, which would spam the announcement); it is a plain labeled, aria-busy + container — NOT a role="dialog" aria-modal (#406: no focus trap exists, so claiming modal + would be a false attribute that leaves the covered app in the tab order). */} {handoff ? "Your story is ready. Entering the table." @@ -266,6 +381,17 @@ function BuildingUniverse({ record, handoff }) { {handoff ? "ready" : `composing · ${elapsedLabel}`}
+ + {/* #405: the manual escape. Appears after ~15s (escapable) so an impatient player — or a + keyboard/screen-reader user — can skip straight to the table at any time. The table is + fully usable on a cold-open (its action bar is enabled, and its own narrating/stuck + recovery engages on the first move), so this never strands the player. Hidden during the + handoff flourish (we're already entering the table). */} + {escapable && !handoff && ( + + )}
); @@ -275,4 +401,5 @@ 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_FLAVOR_LATE = BUILDING_FLAVOR_LATE; window.BUILDING_SUBLINE = BUILDING_SUBLINE; diff --git a/viewer/openworlds/styles.css b/viewer/openworlds/styles.css index b17ff455..196caac3 100644 --- a/viewer/openworlds/styles.css +++ b/viewer/openworlds/styles.css @@ -1097,6 +1097,35 @@ a { color: inherit; text-decoration: none; cursor: pointer; } font-variant-numeric: tabular-nums; } +/* #405: the manual "Enter anyway →" escape, shown after ~15s. Deliberately SECONDARY (a quiet ghost + link, not a brass primary) so it reads as "skip if you're impatient", not "the build failed" — + but a real, focusable