diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx
index 96b2df79..1c818a3b 100644
--- a/viewer/openworlds/app.jsx
+++ b/viewer/openworlds/app.jsx
@@ -254,7 +254,11 @@ function useLiveSession(state) {
const [chatBeats, setChatBeats] = React.useState([]);
const [log, setLog] = React.useState([]); // local optimistic player echoes
- const [pending, setPending] = React.useState(null); // { text, since, stuck? } | null
+ const [pending, setPending] = React.useState(null); // { text, since, stuck?, firstBeat?, streaming? } | null
+ // `streaming` (set in notePendingProgress) means live /events prose has begun arriving for THIS
+ // in-flight turn — the narrating affordance uses it to confirm the scene is being written above,
+ // instead of showing the generic "weaving the next beat" wait. armPending starts a fresh pending
+ // object without it, so each new turn re-derives `streaming` from its own /events arrivals.
const chatCursor = React.useRef(0);
const eventsCursor = React.useRef(0); // #393: per-file cursor for the live /events tail
const dmBeatCountRef = React.useRef(0);
@@ -404,8 +408,14 @@ function useLiveSession(state) {
recoveryTimer.current = window.setTimeout(() => {
setPendingState((q) => (q ? { ...q, stuck: true } : q));
}, recoveryMs);
- // 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));
+ // #G3-UX: fresh prose just streamed via /events for THIS in-flight turn → mark the pending turn
+ // as `streaming`. The narrating affordance reads this to flip its copy from the generic "weaving
+ // the next beat" wait to "the scene is arriving above" — so the spinner is no longer disconnected
+ // from the live narration tail filling in right above it (the player WATCHES the beat being
+ // written instead of staring at a static spinner). Clear any prior 'stuck' flag in the same
+ // update — fresh prose just arrived, so the turn is plainly not stuck. Folding both into one
+ // updater keeps `streaming`/`stuck` mutually consistent and avoids a second state churn.
+ setPendingState((q) => (q ? (q.streaming && !q.stuck ? q : { ...q, streaming: true, stuck: false }) : q));
}, [clearRecoveryTimer, setPendingState]);
// #399: idempotent player echo for STUCK retries only. The #344 'Try again' recovery re-POSTs
diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index 8c8f95b7..fe6bff93 100644
--- a/viewer/openworlds/screen-table.jsx
+++ b/viewer/openworlds/screen-table.jsx
@@ -952,7 +952,13 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
)) :
No moves yet
}
{pendingActive && (
-
+ {/* #G3-UX: `streaming` (set by useLiveSession's notePendingProgress the moment live
+ /events prose lands for this in-flight turn) flips the affordance from a generic
+ "weaving the next beat" wait to confirming the scene is being written ABOVE — so
+ the spinner is wired to the live narration tail the player is watching fill in,
+ not a dead static line. `onNavigate` is passed so the wait can point the player at
+ read-only screens (sheet/map/journal) that stay open during compose. */}
+
)}
{pendingStuck && (
@@ -1318,7 +1324,7 @@ const DM_COLD_OPEN_FLAVOR = [
"The ink is still drying on your opening…",
];
-function DmNarratingBeat({ since, firstBeat }) {
+function DmNarratingBeat({ since, firstBeat, streaming, onNavigate }) {
const start = typeof since === "number" ? since : Date.now();
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
@@ -1331,14 +1337,34 @@ function DmNarratingBeat({ since, firstBeat }) {
const elapsedLabel = `${mm}:${ss}`;
// #385: the headline reads as an ACTIVE process, not a passive status. The cold-open rotates a
// flavor line every ~4s (so the text itself visibly changes); later beats keep the steady label.
+ // #G3-UX: once `streaming` is true, the DM's prose is visibly filling into the chronicle directly
+ // ABOVE this affordance — so the later-beat copy switches from the anticipatory "weaving" wait to
+ // a present-tense confirmation that the scene is arriving NOW. This connects the spinner to the
+ // live /events narration tail the player is watching (the give-up the veteran hit was a dead
+ // static spinner; once prose is flowing the spinner should say so), without changing the cold-open
+ // path (which has its own minutes-long rotating flavor + window).
const label = firstBeat
? DM_COLD_OPEN_FLAVOR[Math.floor(secs / 4) % DM_COLD_OPEN_FLAVOR.length]
- : "The Dungeon Master is narrating";
+ : streaming
+ ? "The scene is unfolding above"
+ : "The Dungeon Master is narrating";
const waitHint = firstBeat
? "The first beat of a session can take a few minutes — hang tight, your story is on its way."
- // #399: a content-rich beat can run up to ~two minutes (the window is 180s); say "a minute or
- // two" so a 90–120s wait reads as expected, not as the app having stalled.
- : "Weaving the next beat — this can take a minute or two.";
+ : streaming
+ // The live prose is appending into the chronicle above this line as the DM writes it — say so,
+ // so the wait reads as visible progress (the scene is arriving) rather than a static spinner.
+ ? "The Dungeon Master is writing this beat — it's appearing above as it's composed."
+ // #399: a content-rich beat can run up to ~two minutes (the window is 180s); say "a minute or
+ // two" so a 90–120s wait reads as expected, not as the app having stalled.
+ : "Weaving the next beat — this can take a minute or two.";
+ // #G3-UX FIX 2: read-only navigation (the character sheet, the map/Travel, the Quest Journal, the
+ // Quick Stash) is already UN-gated during compose — only move/write controls gate on pendingActive.
+ // But a player staring at the wait doesn't KNOW that, so the long beat reads as "frozen, can't do
+ // anything". This one-liner advertises the affordance: the buttons are real `onNavigate` calls (so
+ // it's a working invitation, not just prose), and they route to read-only surfaces that stay open
+ // while the DM composes. Rendered for the normal later-beat wait (the ~120–200s beats the veteran
+ // rage-quit on); the cold-open path keeps its own focused "your story is on its way" reassurance.
+ const showNavAffordance = !firstBeat && typeof onNavigate === "function";
// #385: a11y model for the cold-open. The frozen-app illusion came from the live region being the
// ONLY accessible text AND it never changing (the dots/shimmer/elapsed were all aria-hidden). Fix:
// • The visible label + elapsed are NO LONGER aria-hidden for the first beat, so they appear in
@@ -1384,26 +1410,52 @@ function DmNarratingBeat({ since, firstBeat }) {
);
}
return (
-
- {waitHint}
+ {/* The status line (label + wait hint) is the announced region. Scoping aria-live HERE — not
+ on the whole affordance — means the #G3-UX nav buttons below are NOT re-announced every
+ time the label flips (e.g. when `streaming` turns on), avoiding screen-reader spam while
+ still announcing the single meaningful "the scene is arriving" change. The per-second
+ elapsed counter stays aria-hidden so the polite region isn't re-fired every tick. */}
+
+ {/* #G3-UX FIX 2: tell the player the wait is NOT a freeze — read-only surfaces stay open while
+ the DM composes. The verbs are live onNavigate calls (a working invitation, not just copy)
+ to surfaces that don't gate on pendingActive: the character sheet, the map, the journal.
+ Lives OUTSIDE the aria-live region above so it isn't re-announced on every status change.
+ data-worldos-testid lets the static/jsdom harness assert the affordance renders. */}
+ {showNavAffordance && (
+
+ While you wait, review your
+
+ ·
+
+ ·
+
+
+ )}
);
diff --git a/viewer/tests/test_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py
index f809b80e..fdc41a2e 100644
--- a/viewer/tests/test_live_narration_stream.py
+++ b/viewer/tests/test_live_narration_stream.py
@@ -324,6 +324,50 @@ def test_streamed_beat_shows_prose_but_keeps_turn_gated(self):
self.assertFalse(out["stuck"],
"fresh streamed prose proves the turn is alive — it must not read as stuck")
+ # --- #G3-UX FIX 1: a streamed beat marks the pending turn `streaming` -----------------------
+ # The narrating affordance (DmNarratingBeat) reads `pending.streaming` to flip its copy from the
+ # generic "weaving the next beat" wait to a present-tense confirmation that the scene is arriving
+ # above — wiring the spinner to the live /events tail the player is watching. This asserts the
+ # data wiring: the moment live prose streams for an in-flight turn, notePendingProgress stamps
+ # `streaming: true` (while KEEPING the turn gated — pending present, not stuck).
+ def test_streamed_beat_marks_pending_streaming(self):
+ out = self._run(
+ "h.arm('I push open the door');"
+ "var before = h.pending();"
+ "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'The hinges shriek.' }], next: 1 });"
+ "await h.tick();"
+ "var p = h.pending();"
+ "return ({ before_streaming: !!(before && before.streaming), after_streaming: !!(p && p.streaming), pending_present: !!p, stuck: !!(p && p.stuck) });"
+ )
+ self.assertFalse(out["before_streaming"],
+ "a freshly-armed turn has not streamed yet — `streaming` must start falsy")
+ self.assertTrue(out["after_streaming"],
+ "once live /events prose lands for the in-flight turn, the pending turn must be marked `streaming`")
+ self.assertTrue(out["pending_present"],
+ "marking `streaming` must NOT resolve the turn — the bar stays gated until /chat resolves it")
+ self.assertFalse(out["stuck"],
+ "fresh streamed prose proves the turn is alive — it must not be stuck")
+
+ # A fresh turn must NOT inherit the prior turn's `streaming` flag — armPending starts a clean
+ # pending object, so the affordance re-derives "the scene is arriving" from THIS turn's own
+ # /events arrivals (otherwise every later turn would falsely claim it's already streaming).
+ def test_new_turn_resets_streaming_flag(self):
+ out = self._run(
+ "h.arm('first move');"
+ "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'A first beat.' }], next: 1 });"
+ "await h.tick();"
+ "var first = h.pending();"
+ # resolve the first turn, then arm a second — its pending must start un-streamed.
+ "h.enqueue('/chat', { items: [{ role: 'dm', text: 'A first beat.' }], next: 1 });"
+ "await h.tick();"
+ "h.arm('second move');"
+ "var second = h.pending();"
+ "return ({ first_streaming: !!(first && first.streaming), second_streaming: !!(second && second.streaming) });"
+ )
+ self.assertTrue(out["first_streaming"], "the first turn streamed → it was marked streaming")
+ self.assertFalse(out["second_streaming"],
+ "a newly-armed turn must reset `streaming` to falsy (no prose has streamed for it yet)")
+
# --- #393: the turn-END /chat line RESOLVES a turn whose prose already streamed --------------
def test_chat_resolves_a_streamed_turn(self):
out = self._run(
diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py
index f796ba91..70d23408 100644
--- a/viewer/tests/test_openworlds_static.py
+++ b/viewer/tests/test_openworlds_static.py
@@ -665,6 +665,50 @@ def test_openworlds_table_bounds_and_anchors_the_chronicle(self):
# The action bar is explicitly anchored (never pushed out by a growing chronicle).
self.assertIn('flex: "0 0 auto"', source)
+ def test_openworlds_narrating_beat_reflects_live_stream_and_offers_nav(self):
+ # #G3-UX: the ~120–200s/beat wait was a give-up because the play-screen spinner was a DEAD
+ # static line ("Weaving the next beat…") with no connection to the live /events narration
+ # that #393 already streams into the chronicle above it, and nothing told the player that
+ # read-only screens stay open during compose. Two LOW-RISK fixes, asserted statically here:
+ #
+ # FIX 1 — the spinner is WIRED to the in-flight /events tail. The pending turn carries a
+ # `streaming` flag (set in app.jsx's notePendingProgress the moment live prose lands), passed
+ # into DmNarratingBeat, which flips its copy to confirm the scene is arriving ABOVE — so the
+ # player watches the beat being written instead of a frozen spinner.
+ #
+ # FIX 2 — a visible nav affordance near the narrating beat invites the player to the
+ # read-only character sheet / map / journal (all un-gated during compose) via real
+ # onNavigate calls, so the long wait no longer reads as "frozen, can't do anything".
+ status, ctype, body = self._get("/openworlds/screen-table.jsx")
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+
+ # The pending beat passes the live-stream + nav wiring into DmNarratingBeat.
+ self.assertIn("streaming={Boolean(pending.streaming)}", source)
+ self.assertIn("onNavigate={onNavigate}", source)
+ # DmNarratingBeat accepts the new props.
+ self.assertIn("function DmNarratingBeat({ since, firstBeat, streaming, onNavigate })", source)
+
+ # FIX 1: the later-beat copy is streaming-aware — when prose is arriving it confirms the
+ # scene is being written above, instead of the generic anticipatory "weaving" wait. Both
+ # the streaming and the not-yet-streaming strings must exist (the flip is conditional on
+ # `streaming`), so the spinner is demonstrably connected to the /events tail it sits below.
+ self.assertIn("The scene is unfolding above", source)
+ self.assertIn("appearing above as it's composed", source)
+ # The original anticipatory wait is still the copy BEFORE prose starts arriving.
+ self.assertIn("Weaving the next beat — this can take a minute or two.", source)
+
+ # FIX 2: a testable nav affordance with real onNavigate calls to read-only surfaces.
+ self.assertIn('data-worldos-testid="narrating-nav-affordance"', source)
+ self.assertIn("showNavAffordance", source)
+ self.assertIn('onNavigate("character")', source)
+ self.assertIn('onNavigate("map")', source)
+ self.assertIn('onNavigate("journal")', source)
+ # The affordance is gated to the later-beat path + an actual handler (not the cold-open,
+ # which keeps its own focused reassurance), so it only shows where the player can really act.
+ self.assertIn('const showNavAffordance = !firstBeat && typeof onNavigate === "function";', source)
+
def test_openworlds_table_promotes_action_palette_into_main_column(self):
# #G3: the action palette must be PROMINENT in the main play flow, not buried in the
# 320px right rail. It is rendered in the CENTER column (LEFT — Party / CENTER — Scene