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 ( -
+
-
- {label} -
-
- {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. */} +
+
+ {label} +
+
+ {waitHint} +
+ {/* #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