diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index 3d526314..0f79ba3e 100644
--- a/viewer/openworlds/screen-table.jsx
+++ b/viewer/openworlds/screen-table.jsx
@@ -481,6 +481,41 @@ function computePlayGate({ surfaceStatus, appStatus, pendingStuck }) {
// Exposed for tests/devtools introspection (additive — the component calls the local fn directly).
if (typeof window !== "undefined") window.computePlayGate = computePlayGate;
+// The cold-open wait affordance gate (RRI 2026-06-09 input-lock give-up). True when the session is
+// LIVE (a DM is attached) but NOTHING has landed yet — no chronicle beats, no seated party, and no
+// in-flight/stuck pending beat already showing a spinner. The disabled action bar + empty chronicle
+// otherwise read as a frozen crash through the minutes-long cold-open (newbie: "input locked 9+ min,
+// no feedback"); this lets the table render the existing first-beat DmNarratingBeat so the wait is
+// watchable. Pure read-model derivation; clears the instant a beat or the party arrives, or a player
+// move arms a pending beat. NOTE the live-OR-isLiveView: the real frame is live=true with
+// is_live_view=false (a desync the heal hasn't caught), which is exactly when the bar locks.
+function computeColdOpenAwaiting({ surfaceStatus, live, isLiveView, pendingActive, pendingStuck, visibleLogLength, partyEmpty }) {
+ return Boolean(
+ surfaceStatus === "ready" &&
+ (live || isLiveView) &&
+ !pendingActive && !pendingStuck &&
+ visibleLogLength === 0 &&
+ partyEmpty
+ );
+}
+if (typeof window !== "undefined") window.computeColdOpenAwaiting = computeColdOpenAwaiting;
+
+// Monotonic surface freshness guard (RRI 2026-06-09 wall-of-text rollback). Apply an incoming
+// session surface UNLESS it is a strictly-OLDER snapshot of the SAME campaign already shown — that
+// is a backward-in-time header regression (a mid-beat re-fetch projected an older snapshot, so
+// day/HP/location reverted while the live chronicle held). A different campaign (a real switch), a
+// newer-or-equal snapshot, a first surface, or a payload with no monotonic clock all apply (so older
+// saves without `updated_at` behave exactly as today).
+function shouldApplySurface(prev, next) {
+ if (!prev || !next) return true;
+ if (prev.campaign_id !== next.campaign_id) return true;
+ const a = Number(prev.updated_at);
+ const b = Number(next.updated_at);
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return true;
+ return b >= a;
+}
+if (typeof window !== "undefined") window.shouldApplySurface = shouldApplySurface;
+
function ScreenTable({ onNavigate, state, setState, liveSession }) {
const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : [];
const activeCampaign =
@@ -606,7 +641,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
statusPayload = null;
}
if (isCancelled()) return;
- setSurface(payload);
+ // Reject a strictly-older same-campaign snapshot (the wall-of-text rollback): keep the newer
+ // surface already rendered rather than regress the header backward in time. A later poll lands
+ // the caught-up snapshot. app-status (live/can_act) is independent of the header, so it updates.
+ setSurface((prev) => (shouldApplySurface(prev, payload) ? payload : prev));
setAppStatus(statusPayload);
setSurfaceStatus("ready");
} catch (error) {
@@ -726,6 +764,23 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
// (alive) rather than the generic "Narrating…" — so the locked bar matches the obviously-alive
// chronicle indicator and never looks like the app is wedged.
const pendingFirstBeat = Boolean(pendingActive && pending.firstBeat);
+ // The cold-open wait: render a watchable "DM is opening your world" beat instead of a frozen
+ // empty chronicle + locked bar (the input-lock give-up). Derived from the read-model only.
+ const coldOpenAwaiting = computeColdOpenAwaiting({
+ surfaceStatus,
+ live: Boolean(surface && surface.live),
+ isLiveView: Boolean(surface && surface.is_live_view),
+ pendingActive,
+ pendingStuck,
+ visibleLogLength: visibleLog.length,
+ partyEmpty: !surface || !Array.isArray(surface.party) || surface.party.length === 0,
+ });
+ const coldOpenSinceRef = React.useRef(null);
+ if (coldOpenAwaiting) {
+ if (coldOpenSinceRef.current == null) coldOpenSinceRef.current = Date.now();
+ } else {
+ coldOpenSinceRef.current = null;
+ }
// #344: remember the move that is currently in flight so the "Try again" recovery (shown when a
// turn goes `stuck`) can actually RE-POST it. The first submit clears the input box (setInput("")),
// so by the time the bar re-opens stuck the box is empty — without the original move stored, the
@@ -1061,7 +1116,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
>
- )) :
No moves yet
}
+ )) : coldOpenAwaiting ? null : No moves yet
}
{pendingActive && (
{/* #G3-UX: `streaming` (set by useLiveSession's notePendingProgress the moment live
@@ -1078,6 +1133,15 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
)}
+ {coldOpenAwaiting && (
+ /* The cold-open wait: the DM is composing the opening but nothing has landed and the
+ bar is locked — render the first-beat narrating affordance so the minutes-long wait
+ reads as a turn-in-progress, not a crash (the input-lock give-up). Mutually exclusive
+ with the pending beats above (the selector excludes pendingActive/pendingStuck). */
+
+
+
+ )}
{/* #G3: PRIMARY action palette — promoted into the MAIN column, anchored in the Chronicle
diff --git a/viewer/server.py b/viewer/server.py
index e8be9d69..046875a3 100644
--- a/viewer/server.py
+++ b/viewer/server.py
@@ -1721,6 +1721,12 @@ def build_session_surface(
return {
"campaign_id": campaign_id,
+ # Monotonic snapshot clock (RRI 2026-06-09 wall-of-text rollback): the client's
+ # shouldApplySurface guard rejects a strictly-OLDER same-campaign surface so a transient
+ # mid-beat re-fetch can't regress the header backward in time (day/HP/location reverting
+ # while the live chronicle held). None when the snapshot predates the field — the guard
+ # then no-ops (applies) so older saves behave exactly as today.
+ "updated_at": snapshot.get("updated_at"),
"title": _text(snapshot.get("title"), campaign_id or "Open Worlds"),
"world": _text(snapshot.get("world_id"), "unknown"),
"day": snapshot.get("day") if isinstance(snapshot.get("day"), int) else None,
diff --git a/viewer/tests/test_live_view_recovery.py b/viewer/tests/test_live_view_recovery.py
index 7a639fa1..c70cb1a8 100644
--- a/viewer/tests/test_live_view_recovery.py
+++ b/viewer/tests/test_live_view_recovery.py
@@ -366,6 +366,20 @@ def test_move_refuses_when_sink_dead(self):
self.assertFalse(body.get("ok"), "a dead sink must refuse the move")
self.assertIn("read-only", str(body.get("reason", "")))
+ # -- the monotonic surface clock (wall-of-text rollback) -------------------
+ def test_session_surface_exposes_updated_at_clock(self):
+ """build_session_surface carries the snapshot's monotonic `updated_at` so the client's
+ shouldApplySurface guard can reject a backward-in-time mid-beat re-fetch (the wall-of-text
+ rollback). Absent on an older save -> None, so the guard no-ops and behaves exactly as
+ today."""
+ snap = dict(_snap("Clocked"))
+ snap["updated_at"] = 1780995303.0
+ surf = server.build_session_surface(snap, campaign_id="c", live=True, is_live_view=True)
+ self.assertEqual(surf["updated_at"], 1780995303.0)
+ surf2 = server.build_session_surface(_snap("NoClock"), campaign_id="c",
+ live=True, is_live_view=True)
+ self.assertIsNone(surf2["updated_at"], "an older snapshot with no clock -> None (guard no-ops)")
+
if __name__ == "__main__":
unittest.main()
diff --git a/viewer/tests/test_recovery_timing.py b/viewer/tests/test_recovery_timing.py
index f022e426..5c5f8489 100644
--- a/viewer/tests/test_recovery_timing.py
+++ b/viewer/tests/test_recovery_timing.py
@@ -396,3 +396,83 @@ def test_ready_surface_and_status_is_unblocked(self):
self.assertFalse(gate["surfaceStatusBlocksPlay"])
self.assertFalse(gate["appStatusBlocksPlay"])
self.assertFalse(gate["livePlayBlocked"])
+
+
+@unittest.skipIf(shutil.which("node") is None, "node is required to transpile + run the JSX selector")
+class ColdOpenAwaitingTests(_BabelHarness):
+ """The cold-open wait affordance gate (RRI 2026-06-09 input-lock give-up): computeColdOpenAwaiting
+ fires the watchable 'DM is opening your world' first-beat affordance ONLY in the cold-open frame —
+ a LIVE session with nothing landed yet (no chronicle, no seated party) and no pending/stuck beat
+ already spinning. Pure selector; the render block in ScreenTable just gates on it."""
+
+ def _awaiting(self, **over):
+ base = dict(surfaceStatus="ready", live=True, isLiveView=False, pendingActive=False,
+ pendingStuck=False, visibleLogLength=0, partyEmpty=True)
+ base.update(over)
+ return self._run("win.computeColdOpenAwaiting(%s)" % json.dumps(base))
+
+ def test_fires_at_the_real_coldopen_frame(self):
+ # The exact bug frame: live=true, is_live_view=false (a desync the heal hasn't caught), the
+ # bar locked, empty chronicle + empty party, no pending beat. RED today (no affordance).
+ self.assertTrue(self._awaiting())
+
+ def test_isliveview_only_also_fires(self):
+ self.assertTrue(self._awaiting(live=False, isLiveView=True))
+
+ def test_not_live_does_not_fire(self):
+ self.assertFalse(self._awaiting(live=False, isLiveView=False),
+ "a read-only / dead session must not show a cold-open spinner")
+
+ def test_seated_party_does_not_fire(self):
+ self.assertFalse(self._awaiting(partyEmpty=False),
+ "once the PC is seated the cold-open affordance must clear")
+
+ def test_existing_log_does_not_fire(self):
+ self.assertFalse(self._awaiting(visibleLogLength=1),
+ "once a beat has landed the chronicle carries it; no cold-open spinner")
+
+ def test_pending_beat_takes_precedence(self):
+ self.assertFalse(self._awaiting(pendingActive=True),
+ "a player-move pending beat shows its own spinner")
+ self.assertFalse(self._awaiting(pendingStuck=True),
+ "a stuck beat shows its own affordance")
+
+ def test_surface_not_ready_does_not_fire(self):
+ self.assertFalse(self._awaiting(surfaceStatus="loading"),
+ "don't mask a real surface outage with a cold-open spinner")
+
+
+@unittest.skipIf(shutil.which("node") is None, "node is required to transpile + run the JSX selector")
+class SurfaceFreshnessTests(_BabelHarness):
+ """shouldApplySurface monotonic guard (RRI 2026-06-09 wall-of-text rollback): a strictly-OLDER
+ same-campaign surface is REJECTED so a mid-beat re-fetch can't regress the header backward in
+ time (day/HP/location reverting while the live chronicle held). Everything else applies."""
+
+ def _apply(self, prev, nxt):
+ return self._run("win.shouldApplySurface(%s, %s)" % (json.dumps(prev), json.dumps(nxt)))
+
+ def test_strictly_older_same_campaign_is_rejected(self):
+ self.assertFalse(self._apply({"campaign_id": "c", "updated_at": 200},
+ {"campaign_id": "c", "updated_at": 100}),
+ "an older same-campaign snapshot must not regress the header")
+
+ def test_newer_same_campaign_applies(self):
+ self.assertTrue(self._apply({"campaign_id": "c", "updated_at": 100},
+ {"campaign_id": "c", "updated_at": 200}))
+
+ def test_equal_same_campaign_applies(self):
+ self.assertTrue(self._apply({"campaign_id": "c", "updated_at": 100},
+ {"campaign_id": "c", "updated_at": 100}))
+
+ def test_different_campaign_always_applies(self):
+ # A real campaign switch is never a 'regression' — apply even if the new one is older.
+ self.assertTrue(self._apply({"campaign_id": "live", "updated_at": 200},
+ {"campaign_id": "other", "updated_at": 100}))
+
+ def test_first_surface_applies(self):
+ self.assertTrue(self._apply(None, {"campaign_id": "c", "updated_at": 100}))
+
+ def test_missing_clock_no_ops(self):
+ # No monotonic signal (an older save without updated_at) -> apply, exactly as today.
+ self.assertTrue(self._apply({"campaign_id": "c"}, {"campaign_id": "c", "updated_at": 100}))
+ self.assertTrue(self._apply({"campaign_id": "c", "updated_at": 100}, {"campaign_id": "c"}))