Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 99 additions & 3 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,63 @@ const _STAGE_DIRECTION = new RegExp(
"\\bof the\\b[^.]{0,30}\\b(?:cold open|beat|act|scene)\\b[^.]{0,30}\\bcomplete\\b" +
")", "i",
);
// (4) #752: INTER-BEAT TRANSITION meta-text — the engine/DM "seam" stage-directions that leaked
// into the chronicle BETWEEN player beats (adversarial confirm sweep: "Engine META-TEXT transition
// phrases leak into the chronicle between player beats"). These announce the move from one beat/
// scene to the next ("Moving on to the next beat", "Transitioning to the next scene", "Scene
// transition", "End of beat", "Beginning the next beat", "Time passes between the beats", "We now
// move to the next part of the story"). The player must see story, never the seams.
//
// HIGH-CONFIDENCE ONLY — never bare "beat"/"scene"/"transition". Legitimate fiction MUST survive:
// • "your heart skips a beat" — "beat" with no transition frame
// • "the tavern scene is loud" — "scene" with no transition frame
// • "a smooth transition from the parapet to the rope" — a PHYSICAL transition (from X to a
// non-structural noun), NOT "…to the next beat/scene". So `transition` only triggers when it
// is bound to "the next beat/scene/part" or is a bare "(scene|beat) transition" note — never
// "transition from <thing>".
// `_STRUCT` is the structural-unit noun ("beat"/"scene"/"part of the story"/"act"/"chapter") these
// stage-directions move between; the patterns require it next to a transition verb/marker.
const _STRUCT = "(?:beat|scene|act|chapter|part of the story|part)";
// The "end of <struct>" / "beginning the <struct>" arms are the over-eager ones: `act`/
// `chapter`/`part` are common in REAL fiction ("the end of the act left them breathless",
// "the close of the scene", "the act of contrition"), so those arms use ONLY the engine's own
// structural terms AND require a forward-transition qualifier (next/this/current/following) —
// the META form is always a forward move ("beginning the next scene"), never a descriptive
// "the close of the scene". This spares real prose (story quality is the north star).
const _STRUCT_FWD = "(?:beat|scene)";
const _BEAT_TRANSITION = new RegExp(
"(?:" +
// "moving on/move on/we (now) move … to the next <struct>" (the verbatim leak form)
"\\b(?:moving on|move on|we (?:now )?move|now (?:we )?move|let'?s move)\\b[^.]{0,24}" +
"\\bto the (?:next |following )?" + _STRUCT + "\\b|" +
// "transition(ing) to the (next) <struct>" — bound to a STRUCTURAL target, not a physical one
"\\btransition(?:ing|s|ed)?\\b[^.]{0,18}\\bto the (?:next |following )?" + _STRUCT + "\\b|" +
// a bare "<struct> transition" note ("Scene transition.", "Beat transition")
"\\b" + _STRUCT + "\\s+transition\\b|" +
// "end of (the/this/next) <struct>" — TERMINAL only: the meta note IS the (short) sentence and
// ends on the engine's own struct ("End of beat.", "End of the scene."). Descriptive fiction
// embeds the phrase mid-sentence ("by the close of the scene, three lay dead") so the struct is
// NOT at the end → not matched. Uses beat|scene only (act/chapter/part are real-fiction words).
"\\b(?:end|close|beginning|start) of (?:the |this )?(?:next |current |following )?" + _STRUCT_FWD + "\\s*[.!?]?\\s*$|" +
// "beginning/starting the (next) <struct>" — terminal only, same rationale.
"\\b(?:begin(?:ning)?|start(?:ing)?) the (?:next |following )?" + _STRUCT_FWD + "\\s*[.!?]?\\s*$|" +
// "(time passes) between the beats/scenes" — the interstitial seam phrasing. NOT "between the
// beats OF the drum" (real fiction): the meta form is terminal, so forbid a following " of ".
"\\bbetween (?:the |these )?(?:beats|scenes)\\b(?!\\s+of\\b)|" +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve prose that happens between musical beats

When in-world narration uses “between the beats” as timing, this new arm matches anywhere in the sentence despite the comment saying the meta form is terminal; _stripScaffoldingSentences then drops the whole sentence. For example, You count the space between the beats, then run. sanitizes to an empty string, so legitimate player-facing narration is removed whenever beats/scenes are used as real fiction rather than wrapper meta; require the terminal meta form or a stronger prefix such as “time passes”.

Useful? React with 👍 / 👎.

// "on to the next <struct>" tail (covers "…and we move on to the next scene")
"\\bon to the next " + _STRUCT + "\\b" +
")", "i",
);
// True when a WHOLE sentence is plot-craft jargon or a stage-direction (drop the sentence).
// The tally is handled separately (in-place excision), so it's NOT a whole-sentence-drop trigger.
function _isScaffoldingSentence(sentence) {
const s = (sentence || "").trim();
if (!s) return false;
return _CRAFT_JARGON.test(s) || _STAGE_DIRECTION.test(s);
return _CRAFT_JARGON.test(s) || _STAGE_DIRECTION.test(s) || _BEAT_TRANSITION.test(s);
}
function _hasScaffolding(text) {
return _TALLY_TEST.test(text) || _CRAFT_JARGON.test(text) || _STAGE_DIRECTION.test(text);
return _TALLY_TEST.test(text) || _CRAFT_JARGON.test(text)
|| _STAGE_DIRECTION.test(text) || _BEAT_TRANSITION.test(text);
}
// Clean scaffolding from one line, preserving the real prose. Two grains:
// 1. excise dice/check TALLY phrases in place (keeps the rest of their sentence);
Expand Down Expand Up @@ -288,6 +336,45 @@ const COMPOSER_MODE_BY_UI = {
// Older beats are still available in full in the Quest Journal.
const CHRONICLE_RENDER_CAP = 50;

// #752: BOUND THE CHRONICLE'S ACCESSIBILITY FOOTPRINT (the headline confirm-sweep fix — flagged
// MAJOR by 3 of 5 personas: "later beats invisible in a11y tree", "Actions section pushed out of
// the a11y tree entirely", "player can't tell if the DM is done", "all action controls hidden from
// the screen reader"). #402 anchored the action bar VISUALLY (a sticky DOM sibling below the log),
// but the accessibility snapshot a screen reader / the QA blind-player reads is LINEAR and rendered
// in DOM ORDER, and the reader caps it (qa/playwright/palette_server.js reads
// `ariaSnapshot().slice(0, 9000)`). The Chronicle (`role="log"`) renders BEFORE the action palette
// + the move composer, so a long run of multi-paragraph DM beats fills the whole snapshot budget and
// the action controls get sliced off ENTIRELY — the felt "I can't find the buttons / can't tell the
// DM finished." A bigger render cap or a sticky bar can't fix a linear, length-capped snapshot.
//
// So we cap the chronicle's A11Y exposure independently of its VISUAL row count: only the most-recent
// CHRONICLE_A11Y_TAIL rendered rows stay in the accessibility tree; older rendered rows (kept fully
// VISIBLE for sighted scroll-back, and preserved IN FULL in the Quest Journal — the #402 "earlier
// beats are in your Quest Journal" summary already names that) are `aria-hidden`. So the chronicle's
// a11y subtree is bounded to a small, predictable size at ANY session length: a screen reader
// announces the latest DM beat, then immediately reaches the Actions/composer. The latest beat is
// ALWAYS exposed (it's the player's most recent reply); the bound only engages once the rendered list
// exceeds the tail (a short early-session chronicle exposes every row). Standard long-log a11y
// pattern (virtualized chat transcripts do the same). Viewer stays READ-ONLY — this is purely how the
// already-rendered, engine-authored prose is exposed to assistive tech.
const CHRONICLE_A11Y_TAIL = 8;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the chronicle under the a11y character cap

In sessions where the newest DM beat(s) are multi-paragraph, exposing the last 8 chronicle rows can still exceed the blind-player snapshot's hard ariaSnapshot().slice(0, 9000) cap before the Actions region is reached. The server preserves full recent-event text up to 64 KiB per row, so a single long latest row or several normal long rows will leave the action controls sliced out even though older rows are aria-hidden; this is exactly the failure this change is meant to prevent. Please bound the exposed chronicle by character budget/summary as well as row count, or ensure Actions precede the unbounded log in the a11y tree.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cap exposed chronicle text by character budget

Fresh evidence in this revision is that CHRONICLE_A11Y_TAIL still exposes 8 complete rows with no character budget, while the blind-player path caps ariaSnapshot() at 9000 chars (qa/playwright/palette_server.js:239-245) and recentEvents rows can carry up to 64 KiB (viewer/server.py:1653-1691). In any session where the latest beat is very long, or the exposed tail contains several multi-KB DM beats, the chronicle can still consume the entire snapshot before the Actions region, so the #752 failure remains; please truncate/summarize the exposed text by character budget or place Actions before the log in the a11y tree.

Useful? React with 👍 / 👎.


// True when the chronicle row at index `i` of `total` rendered rows must be `aria-hidden` — i.e. it
// is OLDER than the most-recent CHRONICLE_A11Y_TAIL rows. Pure + exported so the bound is
// unit-testable without mounting the component (mirrors buildChronicleLog / computePlayGate). When
// the rendered list is at/under the tail, NOTHING is hidden (every beat announced).
function chronicleRowAriaHidden(i, total) {
const n = Number(total) || 0;
const idx = Number(i);
if (!Number.isFinite(idx) || n <= CHRONICLE_A11Y_TAIL) return false;
return idx < n - CHRONICLE_A11Y_TAIL;
}
if (typeof window !== "undefined") {
window.CHRONICLE_RENDER_CAP = CHRONICLE_RENDER_CAP;
window.CHRONICLE_A11Y_TAIL = CHRONICLE_A11Y_TAIL;
window.chronicleRowAriaHidden = chronicleRowAriaHidden;
}

// #405: assemble the chronicle's full ordered, de-duplicated row list from its three sources. Pure
// (no React, no DOM) so the exactly-once + chronological-order contract is unit-testable. The whole
// narration-duplication fix lives here + in app.jsx's useLiveSession dedup:
Expand Down Expand Up @@ -1139,6 +1226,12 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
key={entry.id || `${entry.kind || "n"}-${i}`}
ref={i === lastVisibleLogIndex ? latestBeatRef : null}
data-worldos-testid={i === lastVisibleLogIndex ? "chronicle-latest-beat" : undefined}
// #752: older rendered rows are aria-hidden so the chronicle's accessibility subtree
// stays bounded (the most-recent CHRONICLE_A11Y_TAIL rows only) and the action
// controls below are never sliced off the (length-capped) a11y snapshot. They remain
// fully VISIBLE for sighted scroll-back, and the full history is in the Quest Journal.
// The latest beat is never hidden (chronicleRowAriaHidden keeps the tail exposed).
aria-hidden={chronicleRowAriaHidden(i, renderedLog.length) ? "true" : undefined}
style={{ scrollMarginBlock: 12 }}
>
<LogEntry entry={entry} />
Expand Down Expand Up @@ -1178,7 +1271,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
combat verbs (Attack/Bonus/Reaction) in an "In Combat" group when in combat. Reuses the
EncounterButton component + invokeAction + ACTION_HINTS — the click path is unchanged.
flex 0 0 auto so it stays anchored/visible no matter how long the chronicle grows. */}
<div data-worldos-testid="action-palette" style={{ flex: "0 0 auto", marginTop: 14 }}>
{/* #752: a NAMED a11y region so a screen reader / the QA blind-player can target the action
controls directly ("Actions"), even after a long chronicle — paired with the chronicle's
bounded a11y footprint above, the buttons are always reachable in the snapshot. */}
<div data-worldos-testid="action-palette" role="region" aria-label="Actions — your available moves" style={{ flex: "0 0 auto", marginTop: 14 }}>
<SectionTitle>Actions</SectionTitle>
{!actions.length && (
<div className="body-sm muted" style={{ marginTop: 4 }}>
Expand Down
139 changes: 139 additions & 0 deletions viewer/tests/test_chronicle_a11y_bound.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""#752 — the chronicle must not flood the accessibility tree and bury the action controls.

The 2026-06-15 confirm sweep flagged this MAJOR by 3 of 5 personas (newbie, adversarial,
narrative). Verbatim symptoms:
• "Chronicle log grows into one massive block — later beats invisible in a11y tree"
• "Oversized chronicle log pushes Actions section out of a11y tree entirely"
• "Chronicle log a11y overflow buries action buttons — player can't tell if DM is done"
• "Chronicle log overflows a11y tree, hiding all action controls from screen reader"

FELT MECHANISM (the why, not a rubric): the QA blind-player reads the screen via
`qa/playwright/palette_server.js::ariaText`, which is `ariaSnapshot().slice(0, 9000)` — a
HARD CHAR CAP on the body's accessibility YAML, rendered in DOM order. The Chronicle
(`role="log"`) renders BEFORE the Actions palette + the move composer in the DOM, so once
the chronicle's rendered rows carry many multi-paragraph DM beats their YAML alone exceeds
9000 chars and the Actions / composer are sliced off the snapshot ENTIRELY — exactly "the
player can't tell if the DM is done / can't find the action buttons." #402 anchored the
action bar VISUALLY (a sticky DOM sibling) but the a11y snapshot is LINEAR, so the visual
anchor doesn't help the screen-reader / snapshot path.

THE FIX (viewer-only, READ-ONLY): bound the chronicle's accessibility FOOTPRINT. Only the
most-recent `CHRONICLE_A11Y_TAIL` rendered rows stay in the accessibility tree; older
rendered rows (kept visible for sighted scroll-back, and preserved IN FULL in the Quest
Journal) are marked `aria-hidden` with an accessible "earlier beats are in your Quest
Journal" summary already present (#402). So the chronicle's a11y subtree is bounded to a
small, predictable size REGARDLESS of session length — the latest DM beat is announced, then
the Actions/composer are immediately reachable in the snapshot.

These tests exercise the REAL shipped render (ScreenTable's chronicle map + the action
palette + composer), transpiled from the actual `.jsx` under Node — mirroring the sibling
JS-behavior harnesses (test_chronicle_hygiene.py, test_nav_chronicle_resilience.py). Skipped
where Node is not on PATH.
"""
from __future__ import annotations

import json
import shutil
import subprocess
from pathlib import Path

import pytest

HERE = Path(__file__).resolve().parent
OPENWORLDS = HERE.parent / "openworlds"
SCREEN_TABLE = OPENWORLDS / "screen-table.jsx"
BABEL = OPENWORLDS / "vendor" / "babel-standalone-7.29.0.min.js"


def _node() -> str:
node = shutil.which("node")
if not node:
pytest.skip("node not on PATH; skipping JS-behavior test")
return node


# --------------------------------------------------------------------------------------------- #
# A small a11y-aware renderer for ScreenTable's chronicle map. We reproduce ONLY the exact JSX
# the component emits for the rendered-row list (the `renderedLog.map(...)` block) by calling the
# shipped helpers the component uses — CHRONICLE_RENDER_CAP, CHRONICLE_A11Y_TAIL, and
# chronicleRowAriaHidden — so the test tracks the real exposed constants/predicate, not a copy.
# The component wraps each row in a <div> with `aria-hidden` set by chronicleRowAriaHidden(i, n);
# we assert the bound directly on that pure predicate + the constants it reads.
# --------------------------------------------------------------------------------------------- #
def _eval_screen_table(expr: str):
"""Transpile screen-table.jsx under Node and evaluate `expr` against its window scope."""
program = (
"const fs = require('fs'); const vm = require('vm');\n"
+ "const Babel = require(%s);\n" % json.dumps(str(BABEL))
+ "const src = fs.readFileSync(%s, 'utf8');\n" % json.dumps(str(SCREEN_TABLE))
+ "const code = Babel.transform(src, { presets: ['react'], filename: 'screen-table.jsx' }).code;\n"
+ "function h(type, props, ...children){ return { type: (typeof type==='function'?(type.name||'C'):type),"
+ " props: props||{}, children: children.flat(Infinity).filter(c=>c!=null) }; }\n"
+ "const React = { useState:()=>[null,()=>{}], useRef:()=>({}), useCallback:f=>f, useEffect:()=>{},"
+ " createElement:h, Fragment:'F' };\n"
+ "const sb = { React, console }; sb.window = sb; vm.createContext(sb); vm.runInContext(code, sb);\n"
+ "const __res = (function(){ return (" + expr + "); })();\n"
+ "process.stdout.write(JSON.stringify(__res));\n"
)
proc = subprocess.run(
[_node(), "--input-type=commonjs"], input=program, text=True, capture_output=True, timeout=60
)
if proc.returncode != 0:
raise AssertionError(f"node failed: {proc.stderr}")
return json.loads(proc.stdout)


def test_chronicle_a11y_tail_constant_is_present_and_tight():
# The a11y tail must EXIST and be tight enough that even multi-paragraph beats can't blow the
# ~9000-char snapshot budget before the action controls. (A handful of recent beats, not 50.)
vals = _eval_screen_table(
"({ tail: sb.window.CHRONICLE_A11Y_TAIL, renderCap: sb.window.CHRONICLE_RENDER_CAP })"
)
assert isinstance(vals["tail"], int), "CHRONICLE_A11Y_TAIL must be exported on window"
assert 1 <= vals["tail"] <= 15, (
f"a11y tail {vals['tail']} must be a tight handful of beats so the chronicle can't bury "
"the action controls in the a11y snapshot"
)
assert vals["tail"] <= vals["renderCap"], "the a11y tail can't exceed what is rendered"


def test_action_controls_stay_in_a11y_tree_after_30_chronicle_rows():
# The headline assertion: with 30 rendered chronicle rows, the NEWEST rows stay in the a11y
# tree and the OLDER rows are aria-hidden — so the chronicle's exposed a11y footprint is the
# tight tail, NOT all 30 rows. The action palette + composer (DOM siblings AFTER the log) then
# land within the snapshot budget instead of being sliced off.
out = _eval_screen_table(
"(function(){"
" var n = 30;"
" var hidden = [];"
" for (var i = 0; i < n; i++) hidden.push(sb.window.chronicleRowAriaHidden(i, n));"
" var exposed = hidden.filter(function(x){ return !x; }).length;"
" return { n: n, exposed: exposed, lastHidden: hidden[n-1], firstHidden: hidden[0],"
" tail: sb.window.CHRONICLE_A11Y_TAIL };"
"})()"
)
# Only the tail is exposed to AT; the rest are aria-hidden (still visible for scroll-back).
assert out["exposed"] == out["tail"], (
f"with {out['n']} rows, exactly the {out['tail']}-row a11y tail must stay in the tree "
f"(got {out['exposed']} exposed) so the action controls are not sliced off the snapshot"
)
assert out["lastHidden"] is False, "the NEWEST beat must always stay in the a11y tree"
assert out["firstHidden"] is True, "the OLDEST rendered beat must be aria-hidden once past the tail"


def test_short_chronicle_exposes_every_row_to_a11y_tree():
# No regression for a short session: when the rendered list is at/under the tail, NOTHING is
# aria-hidden — every beat is announced (the bound only engages once the log is long).
out = _eval_screen_table(
"(function(){"
" var tail = sb.window.CHRONICLE_A11Y_TAIL;"
" var n = tail;" # exactly the tail length
" var anyHidden = false;"
" for (var i = 0; i < n; i++) if (sb.window.chronicleRowAriaHidden(i, n)) anyHidden = true;"
" return { n: n, anyHidden: anyHidden };"
"})()"
)
assert out["anyHidden"] is False, (
"a short chronicle (≤ the a11y tail) must expose every row — the a11y bound only engages "
"for a long log, never for an early-session player"
)
Loading
Loading