fix(openworlds): in-browser play-entry + DM-narrating affordance (#326, #327) - #328
Conversation
#326, #327) Two P0 play-loop entrance blockers from the #324 AI playtester (play1, newbie). #326 — the browser dead-end. With no native bridge the launcher led with "Begin a new chronicle" → roster → playAs(), whose no-bridge branch only set a note ("Live play starts from the WorldOS app") and returned: a silent dead-end. "Resume Chronicle" already DID drop a browser player into the table (startPlay's no-bridge path navigates there), but it was a small secondary CTA the newbie never found. Engine/wiring is correct: a harness-pre-minted (or resumable) session reports live+canResume in campaigns.json, /session-surface returns can_act:true (auto-followed campaign), and POST /move + /chat both work in a plain browser — verified empirically. Fix: when a playable session exists (live+canResume, or any resumable save), the launcher now leads with an unmistakable ContinueBanner ("Continue → play") that binds the table to that chronicle and drops the player straight into the live loop. The right-panel CTA routes the same way in-browser (no false "Summoning…"), the roster's no-bridge branch redirects into the live session instead of dead-ending, and "Begin a new chronicle" now honestly says a NEW chronicle needs the desktop app rather than leading to a wall. #327 — the loop looked frozen. After a submit, screen-table only echoed the player's own line; with no pending affordance the unchanged DM's 5–8 min turn read as broken. (/move did reach the DM and /chat did render the reply — the gap was purely the missing indicator.) Fix: a "DM is narrating…" beat + disabled action bar (input, Declare→Narrating, dice, encounter actions) the instant a move posts, cleared the moment a new DM narration beat lands via /chat, with a 12-min safety auto-clear so a dropped beat can't wedge the bar. aria-live + reduced-motion aware. Viewer stays a pure reader (reads surfaces, POSTs /move); engine remains the sole writer. No wire-contract, asset, or _private changes. Validated headless (Playwright, isolated port, seeded harness-style session): launcher Continue → table; submit → narrating banner + disabled bar; DM beat → banner clears + bar re-enables; roster Play-as redirects to the live table.
📝 WalkthroughWalkthroughPR ChangesBrowser Session Continuation
DM Narration Pending State & Feedback
Sequence Diagram(s)sequenceDiagram
participant Player
participant Launcher
participant Roster
participant Table
alt Browser with no bridge
Player->>Launcher: Visit launcher
Launcher->>Launcher: Detect !hasBridge
Launcher->>Launcher: Compute playableCampaign
alt Resumable campaign exists
Launcher->>Launcher: Show ContinueBanner
Player->>Launcher: Click ContinueBanner
Launcher->>Launcher: enterPlayable(campaign)
Launcher->>Table: navigate('table')
else No resumable campaign
Launcher->>Launcher: Show "requires desktop app" message
Player->>Roster: Browse campaigns
end
else Browser with bridge / Native
Player->>Launcher: Visit launcher
Launcher->>Launcher: Show standard summon flow
Player->>Launcher: Select campaign
Launcher->>Launcher: onResume() trigger
end
rect rgba(100, 150, 200, 0.5)
Player->>Table: Submit action (postMove)
Table->>Table: armPending() - set pending=true
Table->>Table: Disable buttons, show DmNarratingBeat
Note over Player: UI blocked, see "DM is narrating..."
Table->>Table: loadSurface() refresh
Table->>Table: Fetch /chat tail
alt DM narration beat arrives
Table->>Table: Clear pending=false
Table->>Table: Hide DmNarratingBeat
Player->>Table: (enabled) Next action
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@viewer/openworlds/screen-launcher.jsx`:
- Around line 325-340: The current CTA fallback wires the "View Chronicle" state
to onResume; change the onCta logic so it picks enterPlayable when
browserPlayable, onResume only when c.canResume is true, and a separate
viewer/open action when c.canResume is false (e.g. call a
viewChronicle/openChronicleTable handler instead of onResume). Locate the
onCta/label calculation around hasBridge, c.canResume, c.live and update the
branching to use the new/view handler for the read-only "View Chronicle" case,
then wire the BrassButton onClick to that handler and keep disabled tied to
summoning.
- Around line 100-104: The ContinueBanner is currently wired to enterPlayable
directly which lets native users bypass the onResume/startPlay provider-minting
path; change the banner's handler so it invokes the existing resume/play flow
instead of enterPlayable (either call onResume(playableCampaign) or
startPlay(playableCampaign) from the ContinueBanner onEnter, or modify
enterPlayable to delegate to onResume/startPlay and ensure it triggers the
provider-minting logic). Update the ContinueBanner onEnter to use the
resume/startPlay entrypoint (or make enterPlayable call onResume/startPlay
internally) so the provider-minting path is always executed when the banner is
used.
In `@viewer/openworlds/screen-table.jsx`:
- Around line 97-103: The dmBeatCountRef is incremented when any narration
arrives but never read, so pending is cleared on stale DM beats; fix by
recording a baseline DM beat count when arming pending in armPending (e.g.,
store the current dmBeatCountRef.current into the pending state or a new ref)
and in the /chat handler only clear setPending(null) when the incoming narration
advances the recorded baseline (i.e., beats include narration that makes
dmBeatCountRef.current > baseline). Update the /chat block that computes
dmArrived and dmBeatCountRef.current and modify armPending to capture the
baseline so the gating uses dmBeatCountRef.current versus that baseline before
calling setPending.
- Around line 599-607: The injected style in ensureDmNarrateStyle (element id
"dm-narrate-style") only disables dmNarratePulse via the app-controlled
attribute html[data-reduced-motion='on']; update the style string to also
include an OS-level media-query override using `@media` (prefers-reduced-motion:
reduce) that applies the same rule (e.g., .dm-narrating-dots span { animation:
none !important; opacity: 0.7; }) so the dmNarratePulse keyframes are disabled
when the user’s system prefers reduced motion while keeping the existing
html[data-reduced-motion='on'] rule intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 921b3966-6cad-43a4-ab26-eb4e3989398c
📒 Files selected for processing (3)
viewer/openworlds/screen-launcher.jsxviewer/openworlds/screen-roster.jsxviewer/openworlds/screen-table.jsx
| {playableCampaign && ( | ||
| <ContinueBanner | ||
| campaign={playableCampaign} | ||
| onEnter={() => enterPlayable(playableCampaign)} | ||
| /> |
There was a problem hiding this comment.
Keep the banner out of the native flow.
Lines 101-104 always route through enterPlayable, so a native user can bypass onResume/startPlay and skip the provider-minting path this PR is supposed to preserve there.
Possible fix
- {playableCampaign && (
+ {!hasBridge && playableCampaign && (
<ContinueBanner
campaign={playableCampaign}
onEnter={() => enterPlayable(playableCampaign)}
/>
)}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {playableCampaign && ( | |
| <ContinueBanner | |
| campaign={playableCampaign} | |
| onEnter={() => enterPlayable(playableCampaign)} | |
| /> | |
| {!hasBridge && playableCampaign && ( | |
| <ContinueBanner | |
| campaign={playableCampaign} | |
| onEnter={() => enterPlayable(playableCampaign)} | |
| /> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@viewer/openworlds/screen-launcher.jsx` around lines 100 - 104, The
ContinueBanner is currently wired to enterPlayable directly which lets native
users bypass the onResume/startPlay provider-minting path; change the banner's
handler so it invokes the existing resume/play flow instead of enterPlayable
(either call onResume(playableCampaign) or startPlay(playableCampaign) from the
ContinueBanner onEnter, or modify enterPlayable to delegate to
onResume/startPlay and ensure it triggers the provider-minting logic). Update
the ContinueBanner onEnter to use the resume/startPlay entrypoint (or make
enterPlayable call onResume/startPlay internally) so the provider-minting path
is always executed when the banner is used.
| {(() => { | ||
| const browserPlayable = !hasBridge && c.canResume && c.live; | ||
| const onCta = browserPlayable ? () => enterPlayable(c) : onResume; | ||
| const label = summoning | ||
| ? "Summoning the Dungeon Master…" | ||
| : browserPlayable | ||
| ? "Continue Chronicle" | ||
| : (c.canResume ? "Resume Chronicle" : "View Chronicle"); | ||
| return ( | ||
| <div style={{ | ||
| marginTop: 24, paddingTop: 16, | ||
| borderTop: "1px solid rgba(140,100,60,0.3)", | ||
| display: "flex", gap: 8, | ||
| }}> | ||
| <BrassButton onClick={onCta} size="lg" style={{ flex: 1 }} disabled={summoning}> | ||
| {label} |
There was a problem hiding this comment.
Don't wire View Chronicle to onResume.
Lines 326-339 still fall back to onResume when c.canResume is false, so the native app can summon a DM behind a read-only label instead of just opening the table.
Possible fix
- const onCta = browserPlayable ? () => enterPlayable(c) : onResume;
+ const onCta = browserPlayable
+ ? () => enterPlayable(c)
+ : c.canResume
+ ? onResume
+ : () => {
+ setState((s) => ({ ...s, activeCampaign: c.id }));
+ onNavigate("table");
+ };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {(() => { | |
| const browserPlayable = !hasBridge && c.canResume && c.live; | |
| const onCta = browserPlayable ? () => enterPlayable(c) : onResume; | |
| const label = summoning | |
| ? "Summoning the Dungeon Master…" | |
| : browserPlayable | |
| ? "Continue Chronicle" | |
| : (c.canResume ? "Resume Chronicle" : "View Chronicle"); | |
| return ( | |
| <div style={{ | |
| marginTop: 24, paddingTop: 16, | |
| borderTop: "1px solid rgba(140,100,60,0.3)", | |
| display: "flex", gap: 8, | |
| }}> | |
| <BrassButton onClick={onCta} size="lg" style={{ flex: 1 }} disabled={summoning}> | |
| {label} | |
| {(() => { | |
| const browserPlayable = !hasBridge && c.canResume && c.live; | |
| const onCta = browserPlayable | |
| ? () => enterPlayable(c) | |
| : c.canResume | |
| ? onResume | |
| : () => { | |
| setState((s) => ({ ...s, activeCampaign: c.id })); | |
| onNavigate("table"); | |
| }; | |
| const label = summoning | |
| ? "Summoning the Dungeon Master…" | |
| : browserPlayable | |
| ? "Continue Chronicle" | |
| : (c.canResume ? "Resume Chronicle" : "View Chronicle"); | |
| return ( | |
| <div style={{ | |
| marginTop: 24, paddingTop: 16, | |
| borderTop: "1px solid rgba(140,100,60,0.3)", | |
| display: "flex", gap: 8, | |
| }}> | |
| <BrassButton onClick={onCta} size="lg" style={{ flex: 1 }} disabled={summoning}> | |
| {label} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@viewer/openworlds/screen-launcher.jsx` around lines 325 - 340, The current
CTA fallback wires the "View Chronicle" state to onResume; change the onCta
logic so it picks enterPlayable when browserPlayable, onResume only when
c.canResume is true, and a separate viewer/open action when c.canResume is false
(e.g. call a viewChronicle/openChronicleTable handler instead of onResume).
Locate the onCta/label calculation around hasBridge, c.canResume, c.live and
update the branching to use the new/view handler for the read-only "View
Chronicle" case, then wire the BrassButton onClick to that handler and keep
disabled tied to summoning.
| // #327: a fresh DM narration beat means the turn resolved — clear the pending indicator | ||
| // so the action bar re-opens and the spinner stops. (Player echoes don't count.) | ||
| const dmArrived = beats.some((b) => b.kind === "narration"); | ||
| if (dmArrived) { | ||
| dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length; | ||
| setPending(null); | ||
| } |
There was a problem hiding this comment.
dmBeatCountRef is written but never read — the "new beat" gating it documents isn't actually wired up.
The comments (Lines 21-22 and 155-157) state that pending is cleared only when a new DM beat arrives, "tracked by dmBeatCount". In practice this block clears pending on any narration beat in the incoming /chat tail, and dmBeatCountRef.current is incremented but never consumed anywhere in the file.
Because armPending is immediately followed by loadSurface() (Line 183-184) and a 5s interval poll is also running, a narration beat that predates the just-submitted move (e.g. a previously queued/unfetched DM beat) can land on the next poll and clear pending before the current move has actually resolved — reopening the action bar prematurely. To honor the documented intent, capture a baseline beat count when arming and require the count to advance past it before clearing.
🐛 Proposed fix to gate clearing on a fresh beat
In the /chat block:
- const dmArrived = beats.some((b) => b.kind === "narration");
- if (dmArrived) {
- dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length;
- setPending(null);
- }
+ const narrationCount = beats.filter((b) => b.kind === "narration").length;
+ if (narrationCount) {
+ dmBeatCountRef.current += narrationCount;
+ // clear only when a beat lands after the move was armed
+ setPending((p) => (p && dmBeatCountRef.current > p.beatBaseline ? null : p));
+ }And capture the baseline in armPending (Lines 158-162):
const armPending = (text) => {
- setPending({ text, since: Date.now() });
+ setPending({ text, since: Date.now(), beatBaseline: dmBeatCountRef.current });
if (pendingTimer.current) window.clearTimeout(pendingTimer.current);
pendingTimer.current = window.setTimeout(() => setPending(null), 12 * 60 * 1000);
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // #327: a fresh DM narration beat means the turn resolved — clear the pending indicator | |
| // so the action bar re-opens and the spinner stops. (Player echoes don't count.) | |
| const dmArrived = beats.some((b) => b.kind === "narration"); | |
| if (dmArrived) { | |
| dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length; | |
| setPending(null); | |
| } | |
| // `#327`: a fresh DM narration beat means the turn resolved — clear the pending indicator | |
| // so the action bar re-opens and the spinner stops. (Player echoes don't count.) | |
| const narrationCount = beats.filter((b) => b.kind === "narration").length; | |
| if (narrationCount) { | |
| dmBeatCountRef.current += narrationCount; | |
| // clear only when a beat lands after the move was armed | |
| setPending((p) => (p && dmBeatCountRef.current > p.beatBaseline ? null : p)); | |
| } |
| // #327: a fresh DM narration beat means the turn resolved — clear the pending indicator | |
| // so the action bar re-opens and the spinner stops. (Player echoes don't count.) | |
| const dmArrived = beats.some((b) => b.kind === "narration"); | |
| if (dmArrived) { | |
| dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length; | |
| setPending(null); | |
| } | |
| const armPending = (text) => { | |
| setPending({ text, since: Date.now(), beatBaseline: dmBeatCountRef.current }); | |
| if (pendingTimer.current) window.clearTimeout(pendingTimer.current); | |
| pendingTimer.current = window.setTimeout(() => setPending(null), 12 * 60 * 1000); | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@viewer/openworlds/screen-table.jsx` around lines 97 - 103, The dmBeatCountRef
is incremented when any narration arrives but never read, so pending is cleared
on stale DM beats; fix by recording a baseline DM beat count when arming pending
in armPending (e.g., store the current dmBeatCountRef.current into the pending
state or a new ref) and in the /chat handler only clear setPending(null) when
the incoming narration advances the recorded baseline (i.e., beats include
narration that makes dmBeatCountRef.current > baseline). Update the /chat block
that computes dmArrived and dmBeatCountRef.current and modify armPending to
capture the baseline so the gating uses dmBeatCountRef.current versus that
baseline before calling setPending.
| (function ensureDmNarrateStyle() { | ||
| if (typeof document === "undefined" || document.getElementById("dm-narrate-style")) return; | ||
| const el = document.createElement("style"); | ||
| el.id = "dm-narrate-style"; | ||
| el.textContent = | ||
| "@keyframes dmNarratePulse{0%,80%,100%{opacity:0.25;transform:scale(0.8)}40%{opacity:1;transform:scale(1)}}" + | ||
| "html[data-reduced-motion='on'] .dm-narrating-dots span{animation:none!important;opacity:0.7}"; | ||
| document.head.appendChild(el); | ||
| })(); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Look for where data-reduced-motion is set, and any prefers-reduced-motion sync logic.
rg -nP "data-reduced-motion" -C3
rg -nP "prefers-reduced-motion" -C3Repository: electricsheephq/WorldOS
Length of output: 15709
Add an explicit prefers-reduced-motion override for dmNarratePulse.
File: viewer/openworlds/screen-table.jsx (lines 599-607)
The injected style currently disables the pulse only via html[data-reduced-motion='on'] ... .dm-narrating-dots span. Since data-reduced-motion is driven by the app (e.g., viewer/openworlds/app.jsx), adding the OS media query here makes the behavior self-sufficient for this specific animation.
♿ Proposed fix to also honor the OS media query
el.textContent =
"`@keyframes` dmNarratePulse{0%,80%,100%{opacity:0.25;transform:scale(0.8)}40%{opacity:1;transform:scale(1)}}" +
+ "`@media` (prefers-reduced-motion: reduce){.dm-narrating-dots span{animation:none!important;opacity:0.7}}" +
"html[data-reduced-motion='on'] .dm-narrating-dots span{animation:none!important;opacity:0.7}";🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@viewer/openworlds/screen-table.jsx` around lines 599 - 607, The injected
style in ensureDmNarrateStyle (element id "dm-narrate-style") only disables
dmNarratePulse via the app-controlled attribute html[data-reduced-motion='on'];
update the style string to also include an OS-level media-query override using
`@media` (prefers-reduced-motion: reduce) that applies the same rule (e.g.,
.dm-narrating-dots span { animation: none !important; opacity: 0.7; }) so the
dmNarratePulse keyframes are disabled when the user’s system prefers reduced
motion while keeping the existing html[data-reduced-motion='on'] rule intact.
…label action bar (Closes #335, #336, #337) (#338) Three play-surface bugs surfaced by the #324 AI playtester (run play2, newbie) on the now-reachable Table screen. All viewer-side (engine stays sole writer); no wire-contract / engine change. #335 [P1, data-integrity] DM-internal "GM Advisory" directives + bare engine tool names (e.g. "record their first memory with `remember`") leaked into the player-facing story scroll via the /chat render — the AI-DM equivalent of a system-prompt leak. Root cause: the /chat tail maps every non-player item to a narration beat with no filtering, so when the DM agent "thinks out loud" the housekeeping prose lands in the player's chronicle. - Hard guard (viewer): new sanitizeNarration() strips GM-advisory headers, the Director debt-nudge family (mirrors engine/director.py::_nudge) and bare/underscored engine-tool lines. Applied at BOTH the /chat ingestion map AND the LogEntry narration render (render-path-complete: also covers engine recentEvents). Line-oriented so a stray advisory line is removed without nuking the real prose around it; a wholly-internal beat is dropped (and no longer falsely clears the #327 pending state). - Soft guard (DM skill): a nudge in dungeon-master/SKILL.md — the advisory is for the DM, never echoed into player narration; pay the debt as fiction, then remember/add_quest silently. #336 [P1] The "DM is narrating…" state (#328) was static — 35–60s turns read as frozen. Added a LIVE elapsed-time readout (0:07 → 0:52 …, the strongest "alive" cue, works under reduced-motion since it's information), a subtle label shimmer, and a "this can take up to a minute" hint. role=status + aria-live polite announce once; the per-second counter + dots are aria-hidden so a screen reader isn't spammed. Reduced-motion stills the dots/shimmer (global token + belt-and-suspenders CSS) while keeping the timer + hint. #337 [P2] The action-bar buttons (Continue/Say/Do/Check/Save), dice (d20/d12/d8/d6), and free-text Declare were unexplained — the newbie ignored them all. Added native title= tooltips (hover + SR description, no new DOM, visible label stays the accessible name) keyed by engine action id, plus a one-line inline hint under the action bar. BrassButton now forwards an optional title. Validation: in-browser Babel transpile OK (vendored 7.29.0); sanitizeNarration logic 22/22 cases incl. the issue's verbatim leak + adversarial prose; rendered DOM confirms 0 directive/tool strings in the player chronicle while real narration survives, the narrating beat shows a ticking 0:NN timer, and all action affordances render; qa/ui_audit_health.sh --quick --axe = axe 0 across all 18 screens; no JS console errors on the real table page. Co-authored-by: Eva <eva@100yen.org>
Closes #326
Closes #327
Two P0 play-loop entrance blockers surfaced by the #324 AI playtester (run
play1, newbie persona). Built onorigin/main@abe5035(the #324/#325 harness merge).Both bugs are in the OpenWorlds viewer UI (
viewer/openworlds/*.jsx). I validated up-front that the engine + browser wiring is already correct — so these are honest UI fixes, not contract changes.#326 — in-browser "Begin a new chronicle" dead-ends
Confirmed root cause
onNavigate("roster")→ pick a hero →playAs(). In a plain browser (!hasBridge),playAs's no-bridge branch only set a note — "Live play starts from the WorldOS app" — and returned. That is the silent dead-end the newbie hit, thengive_up'd. (screen-roster.jsxL204-216)onResume → startPlay, whose no-bridge path isonNavigate("table")— but it's a small secondary CTA on the right detail panel that the newbie never discovered, and it only renders whencanResume.live:true, canResume:true, current:trueincampaigns.json;/session-surfacereturnscan_act:true(the viewer auto-follows the attached campaign,pinned=false);do/say/checkactions areavailable; andPOST /movereturns{ok:true}+ lands in the move sink while/chatreturns DM narration withlive:true. So a browser player CAN reach and drive the loop — they just had no obvious way in.Fix (smallest correct + honest)
ContinueBanner(new) — when a playable session exists (live && canResume, or any resumable save as fallback), the launcher now leads with an unmistakable full-width primary: "Live now · Your chronicle awaits · Continue → play". Clicking it binds the table to that chronicle (enterPlayable) and drops the player straight into the live loop. This is the affordance the harness/newbie was missing.enterPlayableand reads "Continue Chronicle" (no misleading "Summoning the Dungeon Master…"; there's no provider to summon — the session already exists). The native app keeps thestartProviderSessionmint path.#327 — after submitting an action, the loop looks frozen
Confirmed root cause
screen-table.jsxpostMove()POSTed the move and appended the player's own line, then polled — but there was no pending/loading affordance anywhere (confirmed: no "narrating" indicator existed in any OpenWorlds.jsx). The unchanged DM's Act-1 turn takes ~5–8 min, so the UI read as frozen./chat— so the only defect was the missing indicator.Fix
role="status"/aria-live, reduced-motion aware) shown in the chronicle the instant a move posts./chat, with a 12-min safety auto-clear so a dropped beat can never wedge the bar shut.Validation (host-aware — no full engine pytest locally, per repo policy → CI)
Headless Playwright against
viewer/server.pyon an isolated port, seeded harness-style (CLAWDND_PLAYER_MOVES+CLAWDND_VIEWER_CHATset, a canon fixture campaign, a DM opening beat in the chat log). Did not run the fullplay2DM loop (5–8 min/turn, host moderately loaded) — used a targeted repro that drives the exact UI ↔ engine ↔ chat seam instead.Empirically confirmed:
campaigns.json→live:true, canResume:true;/session-surface→can_act:true;POST /move→{ok:true}+ appended to the move sink;/chat→ opening beat withlive:true./chat) → narrating beat clears, DM prose renders, action bar re-enables for the next turn.All three changed
.jsxfiles transpile cleanly under the vendored in-browser Babel.Scope / constraints honored
screen-launcher.jsx,screen-roster.jsx,screen-table.jsx. Viewer stays a pure reader (reads surfaces, POSTs/move); engine remains the sole writer.CLAWDND_*env,clawdnd-*MCP ids, bundle id). No assets / nothing under_private/. Run artifacts live outside the repo (/tmp), and/qa/ui_playtest_runs/+/qa/playwright/node_modules/are already gitignored.Please admin-merge on green CI (not merging here).
Summary by CodeRabbit
New Features
Improvements