From 95cb2edc235c643986684016e71e5f0c631fd028 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 11 Mar 2026 12:39:57 +0100 Subject: [PATCH] Redesign top bar with dedicated round badge and centered combatant name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the combined "Round N — Name" string with a three-zone flex layout: left (prev button + R{n} pill badge), center (prominent combatant name with truncation), right (action buttons + next button). Adds 13 unit tests covering all user stories including layout robustness and empty state. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + .../__tests__/turn-navigation.test.tsx | 219 ++++++++++++++++++ apps/web/src/components/turn-navigation.tsx | 43 ++-- .../checklists/requirements.md | 34 +++ specs/034-topbar-redesign/data-model.md | 29 +++ specs/034-topbar-redesign/plan.md | 61 +++++ specs/034-topbar-redesign/quickstart.md | 39 ++++ specs/034-topbar-redesign/research.md | 53 +++++ specs/034-topbar-redesign/spec.md | 89 +++++++ specs/034-topbar-redesign/tasks.md | 156 +++++++++++++ 10 files changed, 703 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/__tests__/turn-navigation.test.tsx create mode 100644 specs/034-topbar-redesign/checklists/requirements.md create mode 100644 specs/034-topbar-redesign/data-model.md create mode 100644 specs/034-topbar-redesign/plan.md create mode 100644 specs/034-topbar-redesign/quickstart.md create mode 100644 specs/034-topbar-redesign/research.md create mode 100644 specs/034-topbar-redesign/spec.md create mode 100644 specs/034-topbar-redesign/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index 2cf766a..b5bc861 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work: - N/A (no persistence changes — confirm state is ephemeral) (032-inline-confirm-buttons) - TypeScript 5.8, CSS (Tailwind CSS v4) + React 19, Tailwind CSS v4 (033-fix-concentration-glow-clip) - N/A (no persistence changes) (033-fix-concentration-glow-clip) +- N/A (no persistence changes — display-only refactor) (034-topbar-redesign) ## Recent Changes - 032-inline-confirm-buttons: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Tailwind CSS v4, Lucide React, class-variance-authority (cva) diff --git a/apps/web/src/components/__tests__/turn-navigation.test.tsx b/apps/web/src/components/__tests__/turn-navigation.test.tsx new file mode 100644 index 0000000..92871bf --- /dev/null +++ b/apps/web/src/components/__tests__/turn-navigation.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; + +import type { Encounter } from "@initiative/domain"; +import { combatantId } from "@initiative/domain"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TurnNavigation } from "../turn-navigation"; + +afterEach(cleanup); + +function renderNav(overrides: Partial = {}) { + const encounter: Encounter = { + combatants: [ + { id: combatantId("1"), name: "Goblin" }, + { id: combatantId("2"), name: "Conjurer" }, + ], + activeIndex: 0, + roundNumber: 1, + ...overrides, + }; + + return render( + , + ); +} + +describe("TurnNavigation", () => { + describe("US1: Round badge and combatant name", () => { + it("renders the round badge with correct round number", () => { + renderNav({ roundNumber: 3 }); + expect(screen.getByText("R3")).toBeInTheDocument(); + }); + + it("renders the combatant name separately from the round badge", () => { + renderNav(); + const badge = screen.getByText("R1"); + const name = screen.getByText("Goblin"); + expect(badge).toBeInTheDocument(); + expect(name).toBeInTheDocument(); + expect(badge).not.toBe(name); + expect(badge.closest("[class]")).not.toBe(name.closest("[class]")); + }); + + it("does not render an em dash between round and name", () => { + const { container } = renderNav(); + expect(container.textContent).not.toContain("—"); + }); + + it("round badge and combatant name are in separate DOM elements", () => { + renderNav(); + const badge = screen.getByText("R1"); + const name = screen.getByText("Goblin"); + expect(badge.parentElement).not.toBe(name.parentElement); + }); + + it("updates the round badge when round changes", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("R2")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("R3")).toBeInTheDocument(); + expect(screen.queryByText("R2")).not.toBeInTheDocument(); + }); + + it("renders the next combatant name when turn advances", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Goblin")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("Conjurer")).toBeInTheDocument(); + }); + }); + + describe("US2: Layout robustness", () => { + it("applies truncation styles to long combatant names", () => { + const longName = + "Ancient Red Dragon Wyrm of the Northern Wastes and Beyond"; + renderNav({ + combatants: [{ id: combatantId("1"), name: longName }], + }); + const nameEl = screen.getByText(longName); + expect(nameEl.className).toContain("truncate"); + }); + + it("renders three-zone layout with a single-character name", () => { + renderNav({ + combatants: [{ id: combatantId("1"), name: "O" }], + }); + expect(screen.getByText("R1")).toBeInTheDocument(); + expect(screen.getByText("O")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Previous turn" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Next turn" }), + ).toBeInTheDocument(); + }); + + it("keeps all action buttons accessible regardless of name length", () => { + const longName = "A".repeat(60); + renderNav({ + combatants: [{ id: combatantId("1"), name: longName }], + }); + expect( + screen.getByRole("button", { name: "Previous turn" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Next turn" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Roll all initiative", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Manage cached sources", + }), + ).toBeInTheDocument(); + }); + + it("renders a 40-character name without truncation class issues", () => { + const name40 = "A".repeat(40); + renderNav({ + combatants: [{ id: combatantId("1"), name: name40 }], + }); + const nameEl = screen.getByText(name40); + expect(nameEl).toBeInTheDocument(); + // The truncate class is applied but CSS only visually truncates if content overflows + expect(nameEl.className).toContain("truncate"); + }); + }); + + describe("US3: No combatants state", () => { + it("shows the round badge when there are no combatants", () => { + renderNav({ combatants: [], roundNumber: 1 }); + expect(screen.getByText("R1")).toBeInTheDocument(); + }); + + it("shows 'No combatants' placeholder text", () => { + renderNav({ combatants: [] }); + expect(screen.getByText("No combatants")).toBeInTheDocument(); + }); + + it("disables navigation buttons when there are no combatants", () => { + renderNav({ combatants: [] }); + expect( + screen.getByRole("button", { name: "Previous turn" }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next turn" })).toBeDisabled(); + }); + }); +}); diff --git a/apps/web/src/components/turn-navigation.tsx b/apps/web/src/components/turn-navigation.tsx index a14a4f0..957b337 100644 --- a/apps/web/src/components/turn-navigation.tsx +++ b/apps/web/src/components/turn-navigation.tsx @@ -26,34 +26,35 @@ export function TurnNavigation({ const activeCombatant = encounter.combatants[encounter.activeIndex]; return ( -
- +
+
+ + + R{encounter.roundNumber} + +
-
+
{activeCombatant ? ( - <> - Round {encounter.roundNumber} - - {" "} - — {activeCombatant.name} - - + + {activeCombatant.name} + ) : ( No combatants )}
-
+