diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx
index f6c5b45..e1505f6 100644
--- a/src/components/CharacterPanel.tsx
+++ b/src/components/CharacterPanel.tsx
@@ -204,13 +204,39 @@ export function CharacterPanel({
{isSelf && (
<>
{" "}
- (YOU)
+
+ (YOU)
+
>
)}
{isActing && (
<>
{" "}
- (ACTING)
+
+
+ ACTING
+
+ >
+ )}
+ {isWaiting && (
+ <>
+ {" "}
+
+ Waiting
+
>
)}
diff --git a/src/components/GameBoard/GameBoard.tsx b/src/components/GameBoard/GameBoard.tsx
index 89696b2..56f18e8 100644
--- a/src/components/GameBoard/GameBoard.tsx
+++ b/src/components/GameBoard/GameBoard.tsx
@@ -272,7 +272,7 @@ export function GameBoard({
[audio, genreSlug],
);
- const { chapterTitle } = useRunningHeader(messages);
+ const { chapterTitle } = useRunningHeader(messages, characters, currentPlayerId);
// Story 33-11: content signals drive the mobile tab notification badges.
// Each entry is a change-detection scalar for a tab's visible content —
@@ -606,23 +606,28 @@ export function GameBoard({
/>
- {/* Turn status */}
- {characters.length > 1 && activePlayerName && (
+ {/* Turn status — canonical multi-PC turn coordination signal.
+ Renders ONLY the structured TurnStatusPanel ("Waiting on:" widget)
+ which is load-bearing for sealed-letter chargen turns. The plain
+ `[ Paul's turn ]` / `[ Your turn ]` chip was removed as part of the
+ S2-UX banner-cluster dedupe (2026-04-26): the new
+ MultiplayerTurnBanner above the InputBar already announces whose
+ turn it is, the CharacterPanel party-section ACTING badge gives
+ the spatially-located cue, and the InputBar placeholder
+ ("Waiting for X…") covers input gating. Keeping all four was
+ quadruple-banner ambiguity. The structured TurnStatusPanel branch
+ stays because it's a richer per-player roster, not a duplicate
+ of "whose turn it is". */}
+ {characters.length > 1 && activePlayerName && turnStatusEntries.length > 0 && (
- {turnStatusEntries.length > 0 ? (
-
- ) : (
- waitingForPlayer
- ? `[ ${activePlayerName}'s turn ]`
- : "[ Your turn ]"
- )}
+
)}
diff --git a/src/components/GameBoard/__tests__/runningHeader-wiring.test.tsx b/src/components/GameBoard/__tests__/runningHeader-wiring.test.tsx
new file mode 100644
index 0000000..7ece11a
--- /dev/null
+++ b/src/components/GameBoard/__tests__/runningHeader-wiring.test.tsx
@@ -0,0 +1,162 @@
+/**
+ * Wiring test — verifies the running header location chip derives from
+ * PARTY_STATUS (per-PC `current_location`) rather than only from
+ * CHAPTER_MARKER messages.
+ *
+ * Per CLAUDE.md "Every Test Suite Needs a Wiring Test": this drives the chip
+ * through the actual top-level GameBoard component and asserts the chip text
+ * updates when the party-status snapshot moves to a new location, *without*
+ * any page refresh or remount. This guards the S2-UX (c) cache-invalidation
+ * regression where the chip showed a stale "BRIDGE — OUTER COYOTE REACH"
+ * even after the prose moved to Docking Crescent.
+ *
+ * Same file also asserts the S2-UX (d) banner-cluster dedupe — the redundant
+ * `[ Paul's turn ]` chip is no longer rendered when there are no per-player
+ * turn entries.
+ */
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
+import { GameBoard, type GameBoardProps } from "../GameBoard";
+import { ImageBusProvider } from "@/providers/ImageBusProvider";
+import type { CharacterSummary } from "@/types/party";
+
+const originalMatchMedia = window.matchMedia;
+
+beforeAll(() => {
+ // Force desktop breakpoint so the desktop GameBoard layout (with the
+ // running-header div above the dockview workspace) renders. The default
+ // test-setup forces mobile, which routes GameBoard through MobileTabView
+ // and skips the top header entirely.
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ configurable: true,
+ value: (query: string) => ({
+ matches: query.includes("min-width: 1200px"),
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }),
+ });
+});
+
+afterAll(() => {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ configurable: true,
+ value: originalMatchMedia,
+ });
+});
+
+function makeChar(player_id: string, current_location: string): CharacterSummary {
+ return {
+ player_id,
+ name: player_id,
+ character_name: player_id,
+ portrait_url: "",
+ hp: 10,
+ hp_max: 10,
+ status_effects: [],
+ class: "Pilot",
+ level: 1,
+ current_location,
+ };
+}
+
+function renderBoard(props: Partial) {
+ const defaults: GameBoardProps = {
+ messages: [],
+ characters: [makeChar("p1", "Bridge")],
+ onSend: vi.fn(),
+ disabled: false,
+ currentPlayerId: "p1",
+ };
+ const merged = { ...defaults, ...props };
+ return render(
+
+
+ ,
+ );
+}
+
+describe("GameBoard running header — S2-UX (c) wiring", () => {
+ it("renders the running-header chip with the local player's current_location", () => {
+ renderBoard({
+ characters: [makeChar("p1", "Docking Crescent")],
+ currentPlayerId: "p1",
+ });
+ const header = screen.getByTestId("running-header");
+ expect(header).toHaveTextContent("Docking Crescent");
+ });
+
+ it("updates the chip when party state moves to a new location (no remount, no refresh)", () => {
+ const { rerender } = renderBoard({
+ characters: [makeChar("p1", "Bridge — Outer Coyote Reach")],
+ currentPlayerId: "p1",
+ });
+ expect(screen.getByTestId("running-header")).toHaveTextContent(
+ "Bridge — Outer Coyote Reach",
+ );
+
+ // PARTY_STATUS arrives with a new location for the local player. The chip
+ // must reflect it on the very next render, with no other state changes.
+ rerender(
+
+
+ ,
+ );
+ expect(screen.getByTestId("running-header")).toHaveTextContent(
+ "Docking Crescent",
+ );
+ // And the stale value is gone.
+ expect(screen.getByTestId("running-header")).not.toHaveTextContent(
+ "Bridge — Outer Coyote Reach",
+ );
+ });
+});
+
+describe("GameBoard turn indicator — S2-UX (d) banner-cluster dedupe", () => {
+ it("does NOT render the redundant `[ Paul's turn ]` chip when there are no per-player turn entries", () => {
+ // The bottom turn-indicator strip used to render the plain text
+ // "[ Paul's turn ]" / "[ Your turn ]" whenever activePlayerName was set.
+ // That signal was already carried by:
+ // - the MultiplayerTurnBanner above the InputBar,
+ // - the CharacterPanel party-section ACTING badge,
+ // - the InputBar placeholder ("Waiting for X…").
+ // Four banners saying the same thing was the bug. The strip is now
+ // reserved for the structured TurnStatusPanel ("Waiting on:" widget)
+ // and only renders when turnStatusEntries are present.
+ renderBoard({
+ characters: [makeChar("p1", "Bridge"), makeChar("p2", "Bridge")],
+ currentPlayerId: "p1",
+ activePlayerName: "p2",
+ activePlayerId: "p2",
+ waitingForPlayer: "p2",
+ turnStatusEntries: [],
+ });
+ expect(screen.queryByTestId("turn-indicator")).not.toBeInTheDocument();
+ });
+
+ it("structured TurnStatusPanel still renders when entries are present (chargen path preserved)", () => {
+ renderBoard({
+ characters: [makeChar("p1", "Bridge"), makeChar("p2", "Bridge")],
+ currentPlayerId: "p1",
+ activePlayerName: "p2",
+ activePlayerId: "p2",
+ turnStatusEntries: [
+ { player_id: "p1", character_name: "Paul", status: "submitted" },
+ { player_id: "p2", character_name: "John", status: "pending" },
+ ],
+ });
+ expect(screen.getByTestId("turn-indicator")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/__tests__/CharacterPanel.test.tsx b/src/components/__tests__/CharacterPanel.test.tsx
index 3bef32d..37ffb22 100644
--- a/src/components/__tests__/CharacterPanel.test.tsx
+++ b/src/components/__tests__/CharacterPanel.test.tsx
@@ -404,6 +404,143 @@ describe("CharacterPanel — AC-6: integrated party list", () => {
});
});
+// ---------------------------------------------------------------------------
+// S2-UX (a, b): YOU/ACTING badges legible + non-acting peers show Waiting
+// ---------------------------------------------------------------------------
+
+describe("CharacterPanel — S2-UX: turn-state badges are legible and unambiguous", () => {
+ const PARTY = [
+ {
+ player_id: "p1",
+ name: "Kael",
+ character_name: "Kael",
+ portrait_url: "/renders/kael.png",
+ hp: 24,
+ hp_max: 30,
+ status_effects: [],
+ class: "Ranger",
+ level: 3,
+ current_location: "The Rusty Cantina",
+ },
+ {
+ player_id: "p2",
+ name: "Lyra",
+ character_name: "Lyra Dawnforge",
+ portrait_url: "",
+ hp: 30,
+ hp_max: 40,
+ status_effects: [],
+ class: "Cleric",
+ level: 5,
+ current_location: "The Rusty Cantina",
+ },
+ {
+ player_id: "p3",
+ name: "Thane",
+ character_name: "Thane",
+ portrait_url: "",
+ hp: 15,
+ hp_max: 28,
+ status_effects: [],
+ class: "Fighter",
+ level: 4,
+ current_location: "The Rusty Cantina",
+ },
+ ];
+
+ it("renders YOU badge on the local player party row", () => {
+ render(
+ ,
+ );
+ const youBadge = screen.getByTestId("party-member-you-badge-p1");
+ expect(youBadge).toBeInTheDocument();
+ expect(youBadge).toHaveTextContent("(YOU)");
+ });
+
+ it("renders ACTING badge with a pulse dot on the active player row", () => {
+ render(
+ ,
+ );
+ const actingBadge = screen.getByTestId("party-member-acting-badge-p2");
+ expect(actingBadge).toBeInTheDocument();
+ expect(actingBadge).toHaveTextContent(/ACTING/);
+ // The pulse dot is the kinetic signal Alex needs to see at a glance
+ // that someone is mid-turn (per S2-UX (b)).
+ expect(within(actingBadge).getByTestId("party-member-acting-pulse-p2")).toBeInTheDocument();
+ });
+
+ it("ACTING pulse uses animate-pulse so it draws the eye", () => {
+ render(
+ ,
+ );
+ const pulse = screen.getByTestId("party-member-acting-pulse-p2");
+ expect(pulse.className).toMatch(/animate-pulse/);
+ });
+
+ it("ACTING badge is bumped from text-[10px] (the prior undersized class) to text-[11px]", () => {
+ // Regression guard for S2-UX (a) — badge was text-[10px], hard to spot.
+ render(
+ ,
+ );
+ const actingBadge = screen.getByTestId("party-member-acting-badge-p1");
+ expect(actingBadge.className).toMatch(/text-\[11px\]/);
+ });
+
+ it("renders Waiting indicator on every non-acting peer when a turn is in flight", () => {
+ render(
+ ,
+ );
+ // p2 is acting — no waiting badge on its row.
+ expect(
+ screen.queryByTestId("party-member-waiting-badge-p2"),
+ ).not.toBeInTheDocument();
+ // p1 (local) and p3 (peer) are waiting — both rows get the badge so
+ // Alex can see at a glance "the table is waiting on Lyra, not me".
+ expect(screen.getByTestId("party-member-waiting-badge-p1")).toBeInTheDocument();
+ expect(screen.getByTestId("party-member-waiting-badge-p3")).toBeInTheDocument();
+ });
+
+ it("does NOT render any Waiting / ACTING badges when no player is acting", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByTestId("party-member-waiting-badge-p1")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("party-member-waiting-badge-p2")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("party-member-waiting-badge-p3")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("party-member-acting-badge-p1")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("party-member-acting-badge-p2")).not.toBeInTheDocument();
+ });
+});
+
// ---------------------------------------------------------------------------
// HP badge in the header — Sebastien-axis (mechanical visibility)
// ---------------------------------------------------------------------------
diff --git a/src/hooks/__tests__/useRunningHeader.test.tsx b/src/hooks/__tests__/useRunningHeader.test.tsx
new file mode 100644
index 0000000..2e3c669
--- /dev/null
+++ b/src/hooks/__tests__/useRunningHeader.test.tsx
@@ -0,0 +1,109 @@
+import { renderHook } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { useRunningHeader } from "@/hooks/useRunningHeader";
+import { MessageType, type GameMessage } from "@/types/protocol";
+import type { CharacterSummary } from "@/types/party";
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+function chapterMarker(location: string, ts = "2026-04-26T12:00:00Z"): GameMessage {
+ return {
+ type: MessageType.CHAPTER_MARKER,
+ timestamp: ts,
+ payload: { location },
+ } as unknown as GameMessage;
+}
+
+function character(id: string, current_location: string): CharacterSummary {
+ return {
+ player_id: id,
+ name: id,
+ character_name: id,
+ portrait_url: "",
+ hp: 10,
+ hp_max: 10,
+ status_effects: [],
+ class: "",
+ level: 1,
+ current_location,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// S2-UX (c): location chip prefers PARTY_STATUS over stale CHAPTER_MARKER
+// ---------------------------------------------------------------------------
+
+describe("useRunningHeader — S2-UX (c) location chip cache invalidation", () => {
+ it("falls back to most recent CHAPTER_MARKER when no party data is provided", () => {
+ const messages = [
+ chapterMarker("Bridge — Outer Coyote Reach"),
+ chapterMarker("Docking Crescent"),
+ ];
+ const { result } = renderHook(() => useRunningHeader(messages));
+ expect(result.current.chapterTitle).toBe("Docking Crescent");
+ });
+
+ it("returns null when no chapter markers and no party data", () => {
+ const { result } = renderHook(() => useRunningHeader([]));
+ expect(result.current.chapterTitle).toBeNull();
+ });
+
+ it("PREFERS local player's current_location over a stale CHAPTER_MARKER", () => {
+ // The cache-invalidation bug: chapter marker says BRIDGE but the prose
+ // (and the party state) has moved to Docking Crescent. The chip should
+ // follow PARTY_STATUS, not the stale marker.
+ const messages = [chapterMarker("Bridge — Outer Coyote Reach")];
+ const characters = [character("p1", "Docking Crescent")];
+ const { result } = renderHook(() =>
+ useRunningHeader(messages, characters, "p1"),
+ );
+ expect(result.current.chapterTitle).toBe("Docking Crescent");
+ });
+
+ it("uses local player's location even when no chapter marker has fired", () => {
+ const characters = [character("p1", "Engine Room")];
+ const { result } = renderHook(() =>
+ useRunningHeader([], characters, "p1"),
+ );
+ expect(result.current.chapterTitle).toBe("Engine Room");
+ });
+
+ it("ignores non-local party members (the chip is per-PC, not party-wide)", () => {
+ // Split party: I'm at Docking Crescent, my peer is on the Bridge.
+ // The chip must reflect MY location, not a peer's.
+ const characters = [
+ character("p1", "Docking Crescent"),
+ character("p2", "Bridge — Outer Coyote Reach"),
+ ];
+ const { result } = renderHook(() =>
+ useRunningHeader([], characters, "p1"),
+ );
+ expect(result.current.chapterTitle).toBe("Docking Crescent");
+ });
+
+ it("falls back to chapter marker when local player's current_location is empty", () => {
+ const messages = [chapterMarker("Bridge")];
+ const characters = [character("p1", "")];
+ const { result } = renderHook(() =>
+ useRunningHeader(messages, characters, "p1"),
+ );
+ expect(result.current.chapterTitle).toBe("Bridge");
+ });
+
+ it("updates the chip when party state moves to a new location (no remount, no refresh)", () => {
+ // The cross-turn refresh case. Mount with one location, then re-render
+ // with a new one — the chip must update without a page refresh.
+ const initialChars = [character("p1", "Bridge")];
+ const { result, rerender } = renderHook(
+ ({ chars }: { chars: CharacterSummary[] }) =>
+ useRunningHeader([], chars, "p1"),
+ { initialProps: { chars: initialChars } },
+ );
+ expect(result.current.chapterTitle).toBe("Bridge");
+
+ rerender({ chars: [character("p1", "Docking Crescent")] });
+ expect(result.current.chapterTitle).toBe("Docking Crescent");
+ });
+});
diff --git a/src/hooks/useRunningHeader.ts b/src/hooks/useRunningHeader.ts
index f448691..2cef3f2 100644
--- a/src/hooks/useRunningHeader.ts
+++ b/src/hooks/useRunningHeader.ts
@@ -1,13 +1,37 @@
import { useMemo } from "react";
import { MessageType, type GameMessage } from "@/types/protocol";
+import type { CharacterSummary } from "@/types/party";
/**
- * Derive the most recent chapter/location title from the message stream.
- * Used for the running header at the top of the game board so the player
- * always knows where they are.
+ * Derive the most recent chapter/location title for the running header.
+ *
+ * Source preference (most fresh → least fresh):
+ * 1. Local player's `current_location` from PARTY_STATUS — pushed every turn,
+ * so this is the canonical "where is my PC right now" signal. Fixes the
+ * cache-invalidation bug (S2-UX (c), 2026-04-26) where the chip showed a
+ * stale location because no CHAPTER_MARKER had fired yet.
+ * 2. Most recent `CHAPTER_MARKER.location` in the message stream — useful as
+ * a fallback before the first PARTY_STATUS arrives or for solo sessions
+ * that emit chapter markers without a party update.
+ *
+ * If neither source has a value, returns `chapterTitle: null` so the caller
+ * can render a non-breaking-space placeholder.
*/
-export function useRunningHeader(messages: GameMessage[]) {
+export function useRunningHeader(
+ messages: GameMessage[],
+ characters: CharacterSummary[] = [],
+ currentPlayerId?: string,
+) {
return useMemo(() => {
+ // Prefer the local player's current_location from PARTY_STATUS.
+ if (currentPlayerId) {
+ const me = characters.find((c) => c.player_id === currentPlayerId);
+ if (me && me.current_location) {
+ return { chapterTitle: me.current_location };
+ }
+ }
+ // Solo / pre-PARTY_STATUS fallback: walk the message stream for the
+ // most recent CHAPTER_MARKER.
let chapterTitle: string | null = null;
for (const msg of messages) {
if (msg.type === MessageType.CHAPTER_MARKER) {
@@ -16,5 +40,5 @@ export function useRunningHeader(messages: GameMessage[]) {
}
}
return { chapterTitle };
- }, [messages]);
+ }, [messages, characters, currentPlayerId]);
}