Skip to content

fix(openworlds): in-browser play-entry + DM-narrating affordance (#326, #327) - #328

Merged
100yenadmin merged 1 commit into
mainfrom
fix/326-327-play-entrance
May 29, 2026
Merged

fix(openworlds): in-browser play-entry + DM-narrating affordance (#326, #327)#328
100yenadmin merged 1 commit into
mainfrom
fix/326-327-play-entrance

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented May 29, 2026

Copy link
Copy Markdown
Member

Closes #326
Closes #327

Two P0 play-loop entrance blockers surfaced by the #324 AI playtester (run play1, newbie persona). Built on origin/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

  • The launcher's prominent left-column entries are "Forge a new hero" and "Begin a new chronicle". "Begin a new chronicle" → 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, then give_up'd. (screen-roster.jsx L204-216)
  • "Resume Chronicle" already worked in-browseronResume → startPlay, whose no-bridge path is onNavigate("table") — but it's a small secondary CTA on the right detail panel that the newbie never discovered, and it only renders when canResume.
  • The engine/browser path is correct (validated empirically against a harness-style seeded session): a pre-minted/resumable session reports live:true, canResume:true, current:true in campaigns.json; /session-surface returns can_act:true (the viewer auto-follows the attached campaign, pinned=false); do/say/check actions are available; and POST /move returns {ok:true} + lands in the move sink while /chat returns DM narration with live: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.
  • Right-panel CTA — in-browser, a playable card routes through enterPlayable and reads "Continue Chronicle" (no misleading "Summoning the Dungeon Master…"; there's no provider to summon — the session already exists). The native app keeps the startProviderSession mint path.
  • Roster no-bridge branch — instead of dead-ending, if a live/resumable chronicle exists it redirects into the live table; otherwise it states plainly that starting a NEW chronicle needs the WorldOS desktop app. Never a silent nothing.
  • "Begin a new chronicle" now carries an honest sub-label when there's no bridge (new chronicles need the desktop app; you can still browse the roster / use Continue).

Verdict on "is new-chronicle intentionally app-only?": yes — minting a fresh DM provider session requires the desktop supervisor bridge. The genuine gap was the missing in-browser Continue/Resume entry + the silent dead-end, which is what this fixes. Engine stays the sole writer; the viewer only reads surfaces + POSTs /move.


#327 — after submitting an action, the loop looks frozen

Confirmed root cause

  • screen-table.jsx postMove() 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.
  • The open questions from the issue, answered: (a) there was no loading affordance; (b) browser-submitted moves do reach the DM and the reply does render via /chat — so the only defect was the missing indicator.

Fix

  • A persistent "The Dungeon Master is narrating…" beat (gilt-rule styling + gently pulsing dots, role="status"/aria-live, reduced-motion aware) shown in the chronicle the instant a move posts.
  • The action bar is disabled while pending: input shows the narrating placeholder, Declare → "Narrating…", and the dice + encounter-action buttons all disable — so a newbie can't spam moves at a busy DM.
  • Pending clears the moment a new DM narration beat lands via /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.py on an isolated port, seeded harness-style (CLAWDND_PLAYER_MOVES + CLAWDND_VIEWER_CHAT set, a canon fixture campaign, a DM opening beat in the chat log). Did not run the full play2 DM loop (5–8 min/turn, host moderately loaded) — used a targeted repro that drives the exact UI ↔ engine ↔ chat seam instead.

Empirically confirmed:

  • campaigns.jsonlive:true, canResume:true; /session-surfacecan_act:true; POST /move{ok:true} + appended to the move sink; /chat → opening beat with live:true.
  • Launcher shows the Continue → play banner; clicking it lands on The Session (table) with a live action bar.
  • Submit → player line echoed, "The Dungeon Master is narrating…" beat shown, input disabled ("…is narrating…" placeholder), Declare→"Narrating…" + all dice disabled.
  • DM responds (narration beat appended to /chat) → narrating beat clears, DM prose renders, action bar re-enables for the next turn.
  • Roster "Play as " (no bridge, live session present) → redirects into the live table (old "Live play starts from the WorldOS app" dead-end gone).

All three changed .jsx files transpile cleanly under the vendored in-browser Babel.

Scope / constraints honored

  • 3 files touched: screen-launcher.jsx, screen-roster.jsx, screen-table.jsx. Viewer stays a pure reader (reads surfaces, POSTs /move); engine remains the sole writer.
  • No wire-contract changes (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

    • Browser players can now continue or resume existing campaigns directly in-app
    • Added visual "DM is narrating" indicator with blocked action submission during DM narration
  • Improvements

    • Clearer messaging when starting new campaigns requires the desktop app
    • Action buttons disabled while waiting for DM narration to prevent conflicting submissions

Review Change Stack

 #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.
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

PR #328 fixes two critical playtest blockers by enabling browser-based session continuation and adding DM narration feedback. Players can now resume existing live campaigns directly in the browser without the desktop app, and a "DM is narrating" pending state prevents action spam and displays visual feedback during move resolution.

Changes

Browser Session Continuation

Layer / File(s) Summary
Bridge detection and playable campaign selection
viewer/openworlds/screen-launcher.jsx, viewer/openworlds/screen-roster.jsx
hasBridge detection checks for window.OpenWorldsNative?.hasBridge?.() availability. playableCampaign logic prioritizes campaigns that are both live and canResume, falling back to any canResume campaign.
Launcher entry mechanism and ContinueBanner
viewer/openworlds/screen-launcher.jsx
enterPlayable handler binds activeCampaign and navigates to the table. New ContinueBanner component renders campaign imagery and a context-aware button ("Continue → play" for live, "Resume → play" for resumable). Component is exported on window.
CTA bar refactoring for browser vs native
viewer/openworlds/screen-launcher.jsx
CTA bar selects different primary actions: browsers use enterPlayable for live+resumable campaigns, while native apps continue with existing onResume summon flow.
Roster redirection and browser advisory messaging
viewer/openworlds/screen-roster.jsx, viewer/openworlds/screen-launcher.jsx
Roster detects no-bridge browsers and redirects to existing resumable sessions with a toast, or shows messaging that new chronicles require the desktop app. Launcher adds conditional advisory text in the detail panel.

DM Narration Pending State & Feedback

Layer / File(s) Summary
Pending state setup and move submission
viewer/openworlds/screen-table.jsx
Adds pending state and pendingTimer/dmBeatCountRef refs. armPending() helper sets pending and auto-reset logic. Move submission now arms pending to prevent action spam during DM response time.
Action gating and chat synchronization
viewer/openworlds/screen-table.jsx
postMove and invokeAction block when pending is active with a "one move at a time" toast. Chat tail processing clears pending when a DM narration beat arrives and increments the narration beat count.
UI disabling and DmNarratingBeat indicator
viewer/openworlds/screen-table.jsx
DmNarratingBeat indicator renders when pending is true with aria-live messaging. Dice roll buttons, text input, "Declare" button, and encounter action buttons are disabled. Input placeholder and button label switch to narration-wait wording.
DmNarratingBeat component and animation
viewer/openworlds/screen-table.jsx
New DmNarratingBeat component displays pulsing dots with injected CSS keyframes that honor reduced-motion settings. Exported on window.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • electricsheephq/WorldOS#127: Modifies OpenWorlds launcher's browser resume/CTA behavior and resumable campaign data selection in screen-launcher.jsx.
  • electricsheephq/WorldOS#201: Updates screen-table.jsx's move/action submission flow with action gating and routing logic that interacts with the pending-state gating introduced here.

Poem

🐇 Whiskers twitching with glee

A browser can play, no app is the way!
The DM says "wait" with dots that don't stray,
No ghost-freeze—just knowing the yarn's on its way,
Continue, resume, or narrator's delay,
The loop lives in-browser today! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description is comprehensive, covering root causes, fixes, and validation for both issues, but lacks the required CLA checklist items from the template. Complete the Licensing/CLA section with the required checkboxes indicating CLA agreement and confirmation of no confidential/restricted material.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the two main changes: in-browser play entry for #326 and DM-narrating affordance for #327, matching the changeset scope.
Linked Issues check ✅ Passed Both #326 and #327 objectives are fully addressed: ContinueBanner surfaces in-browser play entry, roster no-bridge redirects to live table, and DmNarratingBeat with disabled action bar prevents frozen UI appearance.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to the two linked issues: three viewer files modified to fix entry/affordance UI; no engine contract, assets, or unrelated code touched.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between abe5035 and adc6b3d.

📒 Files selected for processing (3)
  • viewer/openworlds/screen-launcher.jsx
  • viewer/openworlds/screen-roster.jsx
  • viewer/openworlds/screen-table.jsx

Comment on lines +100 to +104
{playableCampaign && (
<ContinueBanner
campaign={playableCampaign}
onEnter={() => enterPlayable(playableCampaign)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
{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.

Comment on lines +325 to +340
{(() => {
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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
{(() => {
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.

Comment on lines +97 to +103
// #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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
// #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));
}
Suggested change
// #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.

Comment on lines +599 to +607
(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);
})();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 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" -C3

Repository: 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.

@100yenadmin
100yenadmin merged commit 2a02417 into main May 29, 2026
7 checks passed
@100yenadmin
100yenadmin deleted the fix/326-327-play-entrance branch May 29, 2026 20:42
100yenadmin added a commit that referenced this pull request May 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant