diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index 5f067387..1fae26b4 100644
--- a/viewer/openworlds/screen-table.jsx
+++ b/viewer/openworlds/screen-table.jsx
@@ -281,6 +281,15 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
const actions = Array.isArray(surface?.availableActions) ? surface.availableActions : [];
const enabledActions = Array.isArray(surface?.enabledActions) ? surface.enabledActions : actions.filter((a) => a?.available);
const blockedActions = Array.isArray(surface?.blockedActions) ? surface.blockedActions : actions.filter((a) => !a?.available);
+ // #G3: split the action model by group so the MAIN-column palette can show exploration verbs
+ // (say/do/check/continue/cast/use) always, and combat verbs (attack/bonus/reaction) only when a
+ // fight is on. No truncation — every verb the read model emits renders (the old right-rail
+ // slice(0,6) silently dropped bonus-action + reaction). `actionsInCombat` keys off the same
+ // engine-mutated combat gauge the read model uses (any combat verb is enabled, or the encounter
+ // is flagged active) — never off fiction.
+ const explorationActions = actions.filter((a) => a.group !== "combat");
+ const combatActions = actions.filter((a) => a.group === "combat");
+ const actionsInCombat = Boolean(surface?.encounter?.active) || combatActions.some((a) => a.available);
const writeLane = surface?.writeLane || { endpoint: surface?.write_lane || "/move" };
const actionContext = surface?.actionContext || {};
const consequenceContext = actionContext?.consequences || {};
@@ -657,7 +666,61 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
{pendingStuck && }
- {/* Action bar — #402: flex 0 0 auto so it is ALWAYS anchored at the bottom of the panel and
+ {/* #G3: PRIMARY action palette — promoted into the MAIN column, anchored in the Chronicle
+ panel footer directly above the Declare box. This is the obvious, unmissable way to act
+ in the main play flow (it is NOT buried in the right rail any more). ALL relevant verbs
+ render — no slice(0,6) cap: exploration verbs (Say/Do/Check/Continue/Cast/Use) always,
+ 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. */}
+
+ Actions
+ {!actions.length && (
+
+ No actions are available until a campaign snapshot loads.
+
+
+ {/* DECLARE: free-text action box — the other primary input, paired with the palette above.
+ Action bar — #402: flex 0 0 auto so it is ALWAYS anchored at the bottom of the panel and
never pushed out of view by an ever-growing chronicle above it. */}
{/* #337: one-line hint under the action bar so a first-timer knows free-text + Declare is the core loop, distinct from the quick-action buttons. */}
- Type freely and press Declare, or use the quick actions on the right (hover each for what it does).
+ Type freely and press Declare, or use the Actions above (hover each for what it does).
· {consequenceContext.dueCount} consequence due
)}
No actions are available until a campaign snapshot loads.
}
+ {/* #G3: the actionable palette now lives in the MAIN column beside the Declare
+ box (see ActionPalette below SceneHeader's chronicle panel) so it is the
+ obvious, primary way to act and is never buried in this side rail. This rail
+ keeps only the encounter framing + Round Order. */}
+
+ {actions.length
+ ? "Your moves are in the main column, beside Declare."
+ : "No actions are available until a campaign snapshot loads."}
+ {/* #G4: whiteSpace:"pre-line" honors the DM's blank-line paragraph breaks
+ (the sibling skill PR emits \n\n) so a multi-paragraph beat renders as
+ separated paragraphs instead of one run-on block. sanitizeNarration is
+ still applied above, untouched. */}
+
Chronicle
{text}
diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py
index 7053c8dd..e34eb86f 100644
--- a/viewer/tests/test_openworlds_static.py
+++ b/viewer/tests/test_openworlds_static.py
@@ -211,6 +211,69 @@ def test_openworlds_table_bounds_and_anchors_the_chronicle(self):
# The action bar is explicitly anchored (never pushed out by a growing chronicle).
self.assertIn('flex: "0 0 auto"', source)
+ def test_openworlds_table_promotes_action_palette_into_main_column(self):
+ # #G3: the action palette must be PROMINENT in the main play flow, not buried in the
+ # 320px right rail. It is rendered in the CENTER column (LEFT — Party / CENTER — Scene
+ # + log / RIGHT — Quests), co-located with the free-text Declare box, so a first-time
+ # viewer (or a blind AI playtester) sees clickable actions without hunting in a side rail.
+ status, ctype, body = self._get("/openworlds/screen-table.jsx")
+
+ self.assertEqual(status, 200)
+ self.assertIn("text/babel", ctype)
+ source = body.decode("utf-8")
+ # The three layout-region markers are present and ordered LEFT → CENTER → RIGHT.
+ left = source.index("LEFT — Party")
+ center = source.index("CENTER — Scene")
+ right = source.index("RIGHT — Quests")
+ self.assertLess(left, center)
+ self.assertLess(center, right)
+ # An EncounterButton palette renders inside the CENTER column (between the CENTER and
+ # RIGHT markers) — i.e. the palette is in the main column, not only the right rail.
+ first_button = source.index("Actions<", source)
+ self.assertIn("DECLARE: free-text action box", source)
+ self.assertLess(source.index(">Actions<"), source.index("DECLARE: free-text action box"))
+
+ def test_openworlds_table_renders_all_actions_without_truncation(self):
+ # #G3: the palette must not silently cap the action list. The read model emits up to 8
+ # verbs (exploration: say/do/check/continue/cast/use + combat: attack/bonus/reaction);
+ # the old right-rail `actions.slice(0, 6)` dropped bonus-action + reaction. The palette
+ # now splits by group and renders ALL of each group — no slice cap remains.
+ status, _ctype, body = self._get("/openworlds/screen-table.jsx")
+
+ self.assertEqual(status, 200)
+ source = body.decode("utf-8")
+ # No surviving slice that truncates the action list below the full set.
+ self.assertNotRegex(source, r"actions\.slice\(\s*0\s*,\s*[0-7]\s*\)")
+ # Exploration verbs render always; combat verbs are grouped behind the in-combat gate.
+ self.assertIn("explorationActions", source)
+ self.assertIn("combatActions", source)
+ self.assertIn("actionsInCombat", source)
+ self.assertIn("explorationActions.map", source)
+ self.assertIn("combatActions.map", source)
+ # The grouping keys off the engine-mutated combat gauge (encounter.active / a combat verb
+ # being available), never off fiction — keeping the gates/triggers invariant.
+ self.assertIn("surface?.encounter?.active", source)
+ # The click path is unchanged: the palette still wires through invokeAction.
+ self.assertIn("onClick={() => invokeAction(a)}", source)
+
+ def test_openworlds_table_chronicle_preserves_paragraph_breaks(self):
+ # #G4: a multi-paragraph DM beat must render as separated paragraphs, not one run-on
+ # block. The narration branch renders sanitized {text} in a `div.body`; with the default
+ # white-space the embedded blank-line paragraph breaks the DM emits collapse. The render
+ # honors them via whiteSpace:"pre-line" (and sanitizeNarration is still applied first).
+ status, _ctype, body = self._get("/openworlds/screen-table.jsx")
+
+ self.assertEqual(status, 200)
+ source = body.decode("utf-8")
+ # The narration div opts into preserving newlines…
+ self.assertRegex(source, r'whiteSpace:\s*"pre-line"')
+ # …and the GM-advisory strip is still in the narration path (not removed by this change).
+ self.assertIn("sanitizeNarration(entry.text)", source)
+
def test_openworlds_app_bounds_the_live_session_tail(self):
# #402: the live tail (chatBeats + player echoes) is bounded in useLiveSession so a long
# session doesn't accumulate state without limit (the upstream half of the DOM-growth fix).