From 1d6a05fbc9e4c32b15c3af5044482f37fa277a37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 20:07:36 +0000 Subject: [PATCH 1/9] feat(web): deliver map-first war-room interface overhaul Co-authored-by: G9Pedro --- apps/web/src/features/game/GamePage.tsx | 46 +- .../src/features/game/components/BoardMap.tsx | 129 ++++- .../game/components/TerritoryLayer.tsx | 33 +- .../features/game/components/TurnPanel.tsx | 39 +- apps/web/src/features/game/utils/labels.ts | 12 + apps/web/src/styles/global.css | 446 +++++++++++++++--- 6 files changed, 578 insertions(+), 127 deletions(-) create mode 100644 apps/web/src/features/game/utils/labels.ts diff --git a/apps/web/src/features/game/GamePage.tsx b/apps/web/src/features/game/GamePage.tsx index f5e7e49..24fd5e5 100644 --- a/apps/web/src/features/game/GamePage.tsx +++ b/apps/web/src/features/game/GamePage.tsx @@ -8,6 +8,7 @@ import { CombatPanel } from "./components/CombatPanel"; import { CardsPanel } from "./components/CardsPanel"; import { BattleLogPanel } from "./components/BattleLogPanel"; import { OnboardingTour, shouldShowOnboarding } from "./components/OnboardingTour"; +import { formatPhaseLabel, formatTerritoryLabel } from "./utils/labels"; interface GamePageProps { credentials: SessionCredentials; @@ -56,6 +57,11 @@ export function GamePage({ credentials, onLeaveSession }: GamePageProps) { [credentials.playerId, privateState], ); + const myPublicPlayer = useMemo( + () => publicState?.players.find((player) => player.id === credentials.playerId), + [credentials.playerId, publicState], + ); + async function handleSubmitAction(action: Record) { await submitAction(credentials, action as RiskAction); } @@ -97,13 +103,16 @@ export function GamePage({ credentials, onLeaveSession }: GamePageProps) { } return ( -
-
-
-

Lobby {credentials.lobbyCode}

+
+
+
+

Lobby {credentials.lobbyCode}

+

Global Command

- {isMyTurn ? "Your move." : "Waiting for another commander..."} ·{" "} - Phase: {privateState.currentPhase} + {isMyTurn + ? "Your offensive window is open." + : "Monitoring allied commanders."}{" "} + · Phase: {formatPhaseLabel(privateState.currentPhase)}

@@ -124,14 +133,29 @@ export function GamePage({ credentials, onLeaveSession }: GamePageProps) {
-
+
-
-
+
-
+

Legal Action Hints

{legalActions.length === 0 ? ( -

No legal actions right now.

+

No legal actions available for the current phase.

) : (
    {legalActions.map((hint) => ( diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index 8508698..264cc63 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { GameStatePublic } from "@risk/shared-types"; +import { formatTerritoryLabel } from "../utils/labels"; import { TerritoryLayer } from "./TerritoryLayer"; interface BoardMapProps { @@ -79,7 +80,8 @@ export function BoardMap({ const handlers: Array<{ node: SVGPathElement; - listener: EventListener; + clickListener: EventListener; + keydownListener: EventListener; }> = []; for (const territoryId of Object.keys(state.territories)) { @@ -90,15 +92,32 @@ export function BoardMap({ continue; } - const listener = () => onSelectTerritory(territoryId); - path.addEventListener("click", listener); + const clickListener = () => onSelectTerritory(territoryId); + const keydownListener = (event: Event) => { + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") { + keyboardEvent.preventDefault(); + onSelectTerritory(territoryId); + } + }; + + path.classList.add("territory-region"); + path.setAttribute("tabindex", "0"); + path.setAttribute("role", "button"); + path.setAttribute( + "aria-label", + `Select territory ${formatTerritoryLabel(territoryId)}`, + ); + path.addEventListener("click", clickListener); + path.addEventListener("keydown", keydownListener); path.style.cursor = "pointer"; - handlers.push({ node: path, listener }); + handlers.push({ node: path, clickListener, keydownListener }); } return () => { for (const handler of handlers) { - handler.node.removeEventListener("click", handler.listener); + handler.node.removeEventListener("click", handler.clickListener); + handler.node.removeEventListener("keydown", handler.keydownListener); } }; }, [svgMarkup, state.territories, onSelectTerritory]); @@ -114,16 +133,32 @@ export function BoardMap({ ? playerById.get(territory.ownerId)?.color ?? "#666" : "#5d5d5d"; const stroke = - selectedTerritoryId === territoryId ? "#fff8d6" : "#1f1f1f"; - const strokeWidth = selectedTerritoryId === territoryId ? "3px" : "1.4px"; + selectedTerritoryId === territoryId ? "#f6dd9d" : "rgba(13, 22, 29, 0.88)"; + const strokeWidth = selectedTerritoryId === territoryId ? "3.2px" : "1.5px"; + const fillOpacity = selectedTerritoryId === territoryId + ? 0.88 + : territory.ownerId + ? 0.66 + : 0.28; return ` #${CSS.escape(territoryId)} { fill: ${color} !important; - fill-opacity: ${territory.ownerId ? 0.7 : 0.28} !important; + fill-opacity: ${fillOpacity} !important; stroke: ${stroke} !important; stroke-width: ${strokeWidth} !important; - transition: fill 0.2s ease, stroke 0.2s ease; + filter: ${selectedTerritoryId === territoryId ? "drop-shadow(0 0 7px rgba(246, 221, 157, 0.36))" : "none"}; + transition: + fill 0.2s ease, + stroke 0.2s ease, + fill-opacity 0.2s ease, + filter 0.2s ease; + } + #${CSS.escape(territoryId)}:hover, + #${CSS.escape(territoryId)}:focus-visible { + fill-opacity: ${Math.min(fillOpacity + 0.18, 0.95)} !important; + filter: drop-shadow(0 0 9px rgba(184, 226, 255, 0.28)); + outline: none; } `; }, @@ -132,11 +167,26 @@ export function BoardMap({ return styles.join("\n"); }, [selectedTerritoryId, state.players, state.territories]); + const selectedTerritory = selectedTerritoryId + ? state.territories[selectedTerritoryId] + : null; + const selectedOwner = selectedTerritory?.ownerId + ? state.players.find((player) => player.id === selectedTerritory.ownerId) + : null; + const territoryCount = Object.keys(state.territories).length; + const claimedTerritoryCount = Object.values(state.territories).filter( + (territory) => territory.ownerId, + ).length; + const troopsOnBoard = Object.values(state.territories).reduce( + (sum, territory) => sum + territory.troops, + 0, + ); + const latestBattleEvent = state.battleLog.length + ? state.battleLog[state.battleLog.length - 1] + : null; + return ( -
    -
    -

    World Map

    -
    +
    {svgMarkup ? ( <> @@ -145,11 +195,62 @@ export function BoardMap({ className="risk-board-svg" dangerouslySetInnerHTML={{ __html: svgMarkup }} /> - + ) : (
    Loading board...
    )} + +
    +

    Strategic Theater

    +

    World Command Map

    +

    Drive every decision directly from the battlefield.

    +
    + +
    +
    +
    + {state.players.length} + Commanders +
    +
    + {claimedTerritoryCount}/{territoryCount} + Claimed +
    +
    + {troopsOnBoard} + Total Troops +
    +
    + Round {state.round} + Current Cycle +
    +
    +
    + +
    +

    + {selectedTerritoryId + ? formatTerritoryLabel(selectedTerritoryId) + : "Select a Territory"} +

    + {selectedTerritory ? ( +

    + {selectedOwner + ? `${selectedOwner.name} controls this position with ${selectedTerritory.troops} troops.` + : `This position is unclaimed with ${selectedTerritory.troops} troops.`} +

    + ) : ( +

    Click the map to inspect troop counts and launch commands.

    + )} + {latestBattleEvent ? ( +

    Latest Event: {latestBattleEvent.message}

    + ) : null} +
    ); diff --git a/apps/web/src/features/game/components/TerritoryLayer.tsx b/apps/web/src/features/game/components/TerritoryLayer.tsx index 6777566..53240a7 100644 --- a/apps/web/src/features/game/components/TerritoryLayer.tsx +++ b/apps/web/src/features/game/components/TerritoryLayer.tsx @@ -1,11 +1,22 @@ +import type { CSSProperties } from "react"; import type { GameStatePublic } from "@risk/shared-types"; +import { formatTerritoryLabel } from "../utils/labels"; interface TerritoryLayerProps { state: GameStatePublic; centers: Record; + selectedTerritoryId: string | null; } -export function TerritoryLayer({ state, centers }: TerritoryLayerProps) { +export function TerritoryLayer({ + state, + centers, + selectedTerritoryId, +}: TerritoryLayerProps) { + const playerColorById = new Map( + state.players.map((player) => [player.id, player.color]), + ); + return (
    {Object.entries(state.territories).map(([territoryId, territory]) => { @@ -14,15 +25,23 @@ export function TerritoryLayer({ state, centers }: TerritoryLayerProps) { return null; } + const ownerColor = territory.ownerId + ? playerColorById.get(territory.ownerId) ?? "#7f8f9f" + : "#5d6975"; + const style = { + left: `${center.x}%`, + top: `${center.y}%`, + "--badge-accent": ownerColor, + } as CSSProperties; + return (
    {territory.troops}
    diff --git a/apps/web/src/features/game/components/TurnPanel.tsx b/apps/web/src/features/game/components/TurnPanel.tsx index a5b1500..d7c471c 100644 --- a/apps/web/src/features/game/components/TurnPanel.tsx +++ b/apps/web/src/features/game/components/TurnPanel.tsx @@ -1,4 +1,5 @@ import type { GameStatePrivate, GameStatePublic } from "@risk/shared-types"; +import { formatPhaseLabel } from "../utils/labels"; interface TurnPanelProps { publicState: GameStatePublic; @@ -6,21 +7,28 @@ interface TurnPanelProps { } export function TurnPanel({ publicState, privateState }: TurnPanelProps) { + const currentPlayer = publicState.players.find( + (player) => player.id === publicState.currentPlayerId, + ); + return (
    -

    Turn Status

    -

    - Round: {publicState.round} -

    -

    - Phase: {formatPhase(publicState.currentPhase)} -

    -

    - Current Player:{" "} - {publicState.players.find((player) => player.id === publicState.currentPlayerId)?.name} -

    +
    +

    Turn Status

    + {formatPhaseLabel(publicState.currentPhase)} +
    +
    +
    + Round + {publicState.round} +
    +
    + Reinforcements + {privateState.me.reinforcementPool} +
    +

    - Your Reinforcements: {privateState.me.reinforcementPool} + Current Player: {currentPlayer?.name ?? "Unknown"}

    {publicState.players.map((player) => ( @@ -39,10 +47,3 @@ export function TurnPanel({ publicState, privateState }: TurnPanelProps) {
    ); } - -function formatPhase(phase: string) { - return phase - .split("_") - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(" "); -} diff --git a/apps/web/src/features/game/utils/labels.ts b/apps/web/src/features/game/utils/labels.ts new file mode 100644 index 0000000..200ba6a --- /dev/null +++ b/apps/web/src/features/game/utils/labels.ts @@ -0,0 +1,12 @@ +export function formatPhaseLabel(phase: string): string { + return phase + .split("_") + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +export function formatTerritoryLabel(territoryId: string): string { + return territoryId + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); +} diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 8a5a5bb..fc63257 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -1,11 +1,14 @@ :root { - --bg: #171312; - --panel: rgba(23, 21, 18, 0.86); - --panel-border: rgba(255, 243, 208, 0.22); - --text: #f1ebdc; - --muted: #c9baa0; - --accent: #d2a854; - --error: #ff6f6f; + --bg: #071019; + --panel: rgba(10, 20, 31, 0.82); + --panel-strong: rgba(6, 14, 23, 0.92); + --panel-border: rgba(149, 194, 226, 0.25); + --text: #eaf4ff; + --muted: #9eb2c5; + --accent: #79cfff; + --accent-warm: #f6d08b; + --error: #ff788b; + --shadow-heavy: 0 18px 55px rgba(3, 8, 14, 0.65); } * { @@ -18,11 +21,19 @@ body { color: var(--text); min-height: 100vh; background: - radial-gradient(circle at 20% 10%, rgba(255, 221, 151, 0.15), transparent 35%), - url("/assets/textures/table-texture.svg"), + radial-gradient(circle at 20% -8%, rgba(104, 193, 255, 0.24), transparent 32%), + radial-gradient(circle at 80% 0%, rgba(255, 205, 132, 0.16), transparent 28%), + linear-gradient(180deg, #0a1522 0%, #050d16 60%, #050c14 100%), var(--bg); } +h1, +h2, +h3, +p { + margin: 0; +} + button, input, select { @@ -31,43 +42,61 @@ select { input, select { - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; + border: 1px solid rgba(154, 193, 221, 0.35); + border-radius: 10px; padding: 0.5rem 0.7rem; - background: rgba(0, 0, 0, 0.25); + background: rgba(7, 14, 22, 0.78); color: var(--text); } +input:focus-visible, +select:focus-visible, +button:focus-visible { + outline: 2px solid rgba(121, 207, 255, 0.6); + outline-offset: 1px; +} + button { - border: 1px solid rgba(255, 255, 255, 0.22); - border-radius: 8px; - background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(160, 195, 221, 0.4); + border-radius: 10px; + background: linear-gradient(180deg, rgba(52, 82, 109, 0.78), rgba(31, 48, 64, 0.78)); color: var(--text); padding: 0.5rem 0.8rem; cursor: pointer; + transition: transform 0.16s ease, border-color 0.16s ease, filter 0.16s ease; +} + +button:hover { + border-color: rgba(175, 213, 240, 0.65); + filter: brightness(1.08); +} + +button:active { + transform: translateY(1px); } button.primary { - background: linear-gradient(180deg, #d8b36a 0%, #a67829 100%); - border-color: rgba(0, 0, 0, 0.55); - color: #1d1307; + background: linear-gradient(180deg, #f2ca84 0%, #c3843f 100%); + border-color: rgba(26, 16, 6, 0.7); + color: #1f1204; font-weight: 700; } button:disabled { - opacity: 0.55; + opacity: 0.56; cursor: not-allowed; + transform: none; } .panel { background: - linear-gradient(165deg, rgba(255, 244, 214, 0.06), rgba(0, 0, 0, 0.1)), - url("/assets/textures/parchment-texture.svg"), + linear-gradient(170deg, rgba(150, 205, 241, 0.08), rgba(11, 22, 33, 0.4)), var(--panel); border: 1px solid var(--panel-border); border-radius: 14px; padding: 1rem; - backdrop-filter: blur(2px); + backdrop-filter: blur(8px); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.04); } .stack { @@ -104,8 +133,14 @@ button:disabled { } .hero h1 { - margin: 0 0 0.5rem; - font-size: clamp(1.6rem, 2.8vw, 2.5rem); + margin-bottom: 0.4rem; + font-size: clamp(1.65rem, 2.8vw, 2.6rem); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.hero p { + color: var(--muted); } .lobby-grid { @@ -118,13 +153,14 @@ button:disabled { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; + margin-top: 0.75rem; } .lobby-card { - border: 1px solid rgba(255, 255, 255, 0.15); + border: 1px solid rgba(160, 205, 234, 0.2); border-radius: 10px; padding: 0.8rem; - background: rgba(0, 0, 0, 0.23); + background: rgba(7, 17, 28, 0.56); } .error-banner { @@ -137,72 +173,272 @@ button:disabled { margin: 1rem auto; } -.game-layout { - width: min(1600px, 98vw); - margin: 0.8rem auto 1.2rem; +.war-room-layout { + width: min(1880px, 98vw); + margin: 0.75rem auto 1.2rem; display: grid; - gap: 0.8rem; + gap: 0.85rem; +} + +.war-room-topbar h1 { + font-size: clamp(1.35rem, 2.3vw, 2rem); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.war-room-topbar p { + color: var(--muted); +} + +.war-room-kicker { + text-transform: uppercase; + letter-spacing: 0.17em; + font-size: 0.72rem; + color: var(--accent); + font-weight: 600; } -.game-main-grid { +.war-room-stage { display: grid; - grid-template-columns: minmax(700px, 1fr) minmax(330px, 420px); - gap: 0.8rem; + grid-template-columns: minmax(760px, 1fr) minmax(340px, 430px); + gap: 0.85rem; align-items: start; } -.side-column { - max-height: calc(100vh - 180px); +.command-dock { + max-height: calc(100vh - 170px); overflow: auto; + padding-right: 0.15rem; } -.board-shell { - min-height: 72vh; +.war-room-feed { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(340px, 430px); + gap: 0.85rem; } -.board-title-row h2 { - margin: 0 0 0.5rem; +.board-shell { + min-height: 74vh; } .board-container { position: relative; width: 100%; - border-radius: 12px; + min-height: 74vh; + aspect-ratio: 16 / 10; + border-radius: 18px; overflow: hidden; - background: radial-gradient(circle at top left, rgba(245, 232, 196, 0.18), transparent 35%); - border: 1px solid rgba(255, 245, 222, 0.15); + background: + radial-gradient(circle at 34% 20%, rgba(72, 120, 156, 0.32), rgba(9, 17, 27, 0.96) 70%), + linear-gradient(180deg, rgba(8, 16, 26, 1), rgba(6, 13, 20, 1)); + border: 1px solid rgba(162, 202, 232, 0.26); + box-shadow: var(--shadow-heavy); +} + +.board-loading { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #d7e8f6; + font-weight: 600; + z-index: 4; +} + +.board-container::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at center, transparent 40%, rgba(3, 7, 11, 0.58) 100%), + linear-gradient(180deg, rgba(3, 7, 12, 0.22), rgba(3, 7, 12, 0.5)); + pointer-events: none; + z-index: 3; +} + +.board-container::after { + content: ""; + position: absolute; + inset: 0; + background-image: url("/assets/textures/table-texture.svg"); + opacity: 0.08; + mix-blend-mode: soft-light; + pointer-events: none; + z-index: 2; +} + +.risk-board-svg { + position: absolute; + inset: 0; + z-index: 1; } .risk-board-svg svg { width: 100%; - height: auto; + height: 100%; display: block; - filter: saturate(1.02) contrast(1.08); + filter: saturate(1.16) contrast(1.1) brightness(0.94); +} + +.territory-region { + transition: filter 0.2s ease, fill-opacity 0.2s ease; } .territory-layer { position: absolute; inset: 0; pointer-events: none; + z-index: 5; } .troop-badge { position: absolute; transform: translate(-50%, -50%); - min-width: 1.55rem; + min-width: 1.75rem; text-align: center; border-radius: 999px; - background: rgba(7, 7, 7, 0.85); - border: 1px solid rgba(255, 255, 255, 0.4); - color: #fff; - font-weight: 700; + background: + radial-gradient(circle at 35% 28%, rgba(255, 255, 255, 0.25), transparent 58%), + rgba(8, 12, 18, 0.9); + border: 1px solid rgba(255, 255, 255, 0.58); + outline: 1px solid var(--badge-accent, rgba(150, 185, 211, 0.8)); + color: #f5fbff; + font-weight: 800; + letter-spacing: 0.02em; font-size: 0.72rem; - padding: 0.2rem 0.38rem; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); + padding: 0.2rem 0.4rem; + box-shadow: 0 0 14px rgba(5, 10, 16, 0.75); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.troop-badge.selected { + transform: translate(-50%, -50%) scale(1.14); + box-shadow: + 0 0 0 2px rgba(246, 208, 139, 0.62), + 0 0 20px rgba(246, 208, 139, 0.55); +} + +.map-hud { + position: absolute; + z-index: 6; + pointer-events: none; + max-width: min(420px, 90%); +} + +.map-hud.panel { + background: + linear-gradient(170deg, rgba(198, 231, 255, 0.13), rgba(7, 16, 25, 0.56)), + var(--panel-strong); + border-color: rgba(180, 216, 241, 0.34); + box-shadow: 0 12px 28px rgba(2, 6, 10, 0.52); +} + +.map-hud-top-left { + top: 1rem; + left: 1rem; +} + +.map-hud-top-right { + top: 1rem; + right: 1rem; +} + +.map-hud-bottom-left { + left: 1rem; + bottom: 1rem; +} + +.hud-kicker { + text-transform: uppercase; + letter-spacing: 0.12em; + color: #b5dcf7; + font-size: 0.72rem; + font-weight: 600; +} + +.map-hud h2, +.map-hud h3 { + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 1rem; +} + +.map-hud p { + color: #b9cde0; + font-size: 0.88rem; +} + +.hud-stat-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; +} + +.hud-stat-card { + border: 1px solid rgba(175, 214, 241, 0.2); + border-radius: 10px; + padding: 0.5rem; + background: rgba(6, 14, 22, 0.62); + display: grid; + gap: 0.15rem; +} + +.hud-stat-card strong { + color: #f4fbff; + font-size: 0.95rem; +} + +.hud-stat-card span { + color: #acc2d4; + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.latest-event { + margin-top: 0.2rem; + color: #d4e6f5; + font-size: 0.8rem; } .turn-panel .player-list { - margin-top: 0.3rem; + margin-top: 0.2rem; +} + +.phase-pill { + border: 1px solid rgba(175, 217, 247, 0.38); + border-radius: 999px; + padding: 0.2rem 0.55rem; + font-size: 0.72rem; + color: #d4ecff; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.turn-highlights { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; +} + +.turn-highlight-card { + border: 1px solid rgba(167, 207, 233, 0.2); + border-radius: 10px; + padding: 0.5rem; + display: grid; + gap: 0.2rem; + background: rgba(6, 14, 22, 0.62); +} + +.turn-highlight-card span { + color: #9bb4c8; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.turn-highlight-card strong { + font-size: 1.05rem; } .player-chip { @@ -210,12 +446,16 @@ button:disabled { grid-template-columns: 14px 1fr auto; align-items: center; gap: 0.5rem; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; + background: rgba(6, 15, 24, 0.6); + border: 1px solid rgba(162, 206, 233, 0.2); + border-radius: 9px; padding: 0.45rem 0.55rem; } +.player-chip small { + color: #abc0d0; +} + .player-color { width: 12px; height: 12px; @@ -238,45 +478,53 @@ button:disabled { } .card-chip.selected { - border-color: #f7d38b; - box-shadow: 0 0 0 1px rgba(247, 211, 139, 0.8); -} - -.bottom-grid { - display: grid; - grid-template-columns: 1fr 380px; - gap: 0.8rem; + border-color: #f6d08b; + box-shadow: 0 0 0 1px rgba(246, 208, 139, 0.74); } .battle-log-panel { - min-height: 230px; + min-height: 240px; } .battle-log-list { display: grid; - gap: 0.4rem; - max-height: 220px; + gap: 0.45rem; + max-height: 240px; overflow: auto; + padding-right: 0.15rem; } .log-entry { - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 8px; - padding: 0.45rem 0.55rem; - background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(166, 205, 231, 0.2); + border-radius: 10px; + padding: 0.5rem 0.6rem; + background: rgba(6, 13, 21, 0.66); +} + +.log-entry p { + margin-top: 0.35rem; + color: #b2c6d7; +} + +.legal-intel-panel { + min-height: 240px; } .legal-list { margin: 0; padding-left: 1rem; display: grid; - gap: 0.35rem; + gap: 0.4rem; +} + +.legal-list li { + color: #c4d8e8; } .onboarding-backdrop { position: fixed; inset: 0; - background: rgba(4, 4, 4, 0.7); + background: rgba(3, 8, 13, 0.78); display: grid; place-items: center; z-index: 100; @@ -287,16 +535,62 @@ button:disabled { } .winner-banner { - color: #ffe4a9; + color: #ffdca8; font-weight: 700; } -@media (max-width: 1180px) { - .game-main-grid { +@media (max-width: 1360px) { + .war-room-stage { + grid-template-columns: minmax(0, 1fr) minmax(310px, 390px); + } + + .war-room-feed { grid-template-columns: 1fr; } +} - .bottom-grid { +@media (max-width: 1080px) { + .war-room-stage { grid-template-columns: 1fr; } + + .command-dock { + max-height: unset; + overflow: visible; + } + + .board-container { + min-height: 62vh; + } + + .map-hud-top-right { + display: none; + } +} + +@media (max-width: 820px) { + .lobby-layout, + .war-room-layout { + width: min(96vw, 100%); + } + + .war-room-topbar { + gap: 0.7rem; + flex-direction: column; + align-items: flex-start; + } + + .map-hud { + max-width: calc(100% - 2rem); + } + + .map-hud-bottom-left { + bottom: 0.75rem; + left: 0.75rem; + } + + .map-hud-top-left { + top: 0.75rem; + left: 0.75rem; + } } From fe91c0df0a5944d3b949612559ee6feb87865703 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 20:16:57 +0000 Subject: [PATCH 2/9] fix(web): restore reliable territory selection on map Co-authored-by: G9Pedro --- .../src/features/game/components/BoardMap.tsx | 99 ++++++++++--------- apps/web/src/styles/global.css | 6 ++ 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index 264cc63..49ebb51 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { GameStatePublic } from "@risk/shared-types"; import { formatTerritoryLabel } from "../utils/labels"; import { TerritoryLayer } from "./TerritoryLayer"; @@ -31,7 +31,7 @@ export function BoardMap({ } const root = containerRef.current; - const svgElement = root.querySelector("svg"); + const svgElement = root.querySelector("svg"); if (!svgElement) { return; } @@ -40,9 +40,7 @@ export function BoardMap({ const svgRect = svgElement.getBoundingClientRect(); const nextCenters: Record = {}; for (const territoryId of Object.keys(state.territories)) { - const path = svgElement.querySelector( - `#${CSS.escape(territoryId)}`, - ); + const path = getTerritoryNode(svgElement, territoryId); if (!path) { continue; } @@ -73,21 +71,19 @@ export function BoardMap({ } const root = containerRef.current; - const svgElement = root.querySelector("svg"); + const svgElement = root.querySelector("svg"); if (!svgElement) { return; } const handlers: Array<{ - node: SVGPathElement; + node: SVGGraphicsElement; clickListener: EventListener; keydownListener: EventListener; }> = []; for (const territoryId of Object.keys(state.territories)) { - const path = svgElement.querySelector( - `#${CSS.escape(territoryId)}`, - ); + const path = getTerritoryNode(svgElement, territoryId); if (!path) { continue; } @@ -108,6 +104,7 @@ export function BoardMap({ "aria-label", `Select territory ${formatTerritoryLabel(territoryId)}`, ); + path.style.pointerEvents = "all"; path.addEventListener("click", clickListener); path.addEventListener("keydown", keydownListener); path.style.cursor = "pointer"; @@ -122,49 +119,50 @@ export function BoardMap({ }; }, [svgMarkup, state.territories, onSelectTerritory]); - const territoryStyles = useMemo(() => { + useEffect(() => { + if (!containerRef.current) { + return; + } + + const root = containerRef.current; + const svgElement = root.querySelector("svg"); + if (!svgElement) { + return; + } + const playerById = new Map( state.players.map((player) => [player.id, player]), ); - const styles = Object.entries(state.territories).map( - ([territoryId, territory]) => { - const color = territory.ownerId - ? playerById.get(territory.ownerId)?.color ?? "#666" - : "#5d5d5d"; - const stroke = - selectedTerritoryId === territoryId ? "#f6dd9d" : "rgba(13, 22, 29, 0.88)"; - const strokeWidth = selectedTerritoryId === territoryId ? "3.2px" : "1.5px"; - const fillOpacity = selectedTerritoryId === territoryId - ? 0.88 - : territory.ownerId - ? 0.66 - : 0.28; + for (const [territoryId, territory] of Object.entries(state.territories)) { + const path = getTerritoryNode(svgElement, territoryId); + if (!path) { + continue; + } - return ` - #${CSS.escape(territoryId)} { - fill: ${color} !important; - fill-opacity: ${fillOpacity} !important; - stroke: ${stroke} !important; - stroke-width: ${strokeWidth} !important; - filter: ${selectedTerritoryId === territoryId ? "drop-shadow(0 0 7px rgba(246, 221, 157, 0.36))" : "none"}; - transition: - fill 0.2s ease, - stroke 0.2s ease, - fill-opacity 0.2s ease, - filter 0.2s ease; - } - #${CSS.escape(territoryId)}:hover, - #${CSS.escape(territoryId)}:focus-visible { - fill-opacity: ${Math.min(fillOpacity + 0.18, 0.95)} !important; - filter: drop-shadow(0 0 9px rgba(184, 226, 255, 0.28)); - outline: none; - } - `; - }, - ); + const color = territory.ownerId + ? playerById.get(territory.ownerId)?.color ?? "#666" + : "#5d5d5d"; + const stroke = + selectedTerritoryId === territoryId ? "#f6dd9d" : "rgba(13, 22, 29, 0.88)"; + const strokeWidth = selectedTerritoryId === territoryId ? "3.2px" : "1.5px"; + const fillOpacity = selectedTerritoryId === territoryId + ? 0.88 + : territory.ownerId + ? 0.66 + : 0.28; - return styles.join("\n"); + path.style.fill = color; + path.style.fillOpacity = String(fillOpacity); + path.style.stroke = stroke; + path.style.strokeWidth = strokeWidth; + path.style.transition = + "fill 0.2s ease, stroke 0.2s ease, fill-opacity 0.2s ease, filter 0.2s ease"; + path.style.filter = + selectedTerritoryId === territoryId + ? "drop-shadow(0 0 7px rgba(246, 221, 157, 0.36))" + : "none"; + } }, [selectedTerritoryId, state.players, state.territories]); const selectedTerritory = selectedTerritoryId @@ -190,7 +188,6 @@ export function BoardMap({
    {svgMarkup ? ( <> -
    ); } + +function getTerritoryNode(svgElement: SVGSVGElement, territoryId: string) { + return svgElement.querySelector( + `#${CSS.escape(territoryId)}`, + ); +} diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index fc63257..70022b8 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -284,6 +284,12 @@ button:disabled { transition: filter 0.2s ease, fill-opacity 0.2s ease; } +.territory-region:hover, +.territory-region:focus-visible { + filter: drop-shadow(0 0 9px rgba(184, 226, 255, 0.28)); + outline: none; +} + .territory-layer { position: absolute; inset: 0; From 5a6842b37d91b368d611461f58c6937e6b50a2d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 20:23:33 +0000 Subject: [PATCH 3/9] chore(debug): instrument board map territory click flow Co-authored-by: G9Pedro --- apps/web/src/features/game/GamePage.tsx | 16 +++ .../src/features/game/components/BoardMap.tsx | 102 +++++++++++++++++- .../src/features/game/utils/agentDebugLog.ts | 24 +++++ 3 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/features/game/utils/agentDebugLog.ts diff --git a/apps/web/src/features/game/GamePage.tsx b/apps/web/src/features/game/GamePage.tsx index 24fd5e5..27ecd57 100644 --- a/apps/web/src/features/game/GamePage.tsx +++ b/apps/web/src/features/game/GamePage.tsx @@ -9,6 +9,7 @@ import { CardsPanel } from "./components/CardsPanel"; import { BattleLogPanel } from "./components/BattleLogPanel"; import { OnboardingTour, shouldShowOnboarding } from "./components/OnboardingTour"; import { formatPhaseLabel, formatTerritoryLabel } from "./utils/labels"; +import { agentDebugLog } from "./utils/agentDebugLog"; interface GamePageProps { credentials: SessionCredentials; @@ -52,6 +53,21 @@ export function GamePage({ credentials, onLeaveSession }: GamePageProps) { void refreshLegalActions(credentials); }, [credentials, privateState, refreshLegalActions]); + useEffect(() => { + // #region agent log + agentDebugLog({ + hypothesisId: "D", + location: "GamePage.tsx:selectedTerritoryEffect", + message: "Selected territory state changed", + data: { + selectedTerritoryId, + hasPublicState: Boolean(publicState), + hasPrivateState: Boolean(privateState), + }, + }); + // #endregion + }, [privateState, publicState, selectedTerritoryId]); + const isMyTurn = useMemo( () => privateState?.currentPlayerId === credentials.playerId, [credentials.playerId, privateState], diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index 49ebb51..c3ddf47 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import type { GameStatePublic } from "@risk/shared-types"; import { formatTerritoryLabel } from "../utils/labels"; import { TerritoryLayer } from "./TerritoryLayer"; +import { agentDebugLog } from "../utils/agentDebugLog"; interface BoardMapProps { state: GameStatePublic; @@ -21,8 +22,30 @@ export function BoardMap({ useEffect(() => { fetch("/assets/board/risk-board.svg") .then((response) => response.text()) - .then((text) => setSvgMarkup(text)) - .catch(() => setSvgMarkup("")); + .then((text) => { + setSvgMarkup(text); + // #region agent log + agentDebugLog({ + hypothesisId: "A", + location: "BoardMap.tsx:fetchSvg.then", + message: "Loaded board SVG markup", + data: { + markupLength: text.length, + }, + }); + // #endregion + }) + .catch(() => { + setSvgMarkup(""); + // #region agent log + agentDebugLog({ + hypothesisId: "A", + location: "BoardMap.tsx:fetchSvg.catch", + message: "Failed to load board SVG markup", + data: {}, + }); + // #endregion + }); }, []); useEffect(() => { @@ -76,19 +99,40 @@ export function BoardMap({ return; } + const territoryIds = Object.keys(state.territories); + const territoryIdSet = new Set(territoryIds); const handlers: Array<{ node: SVGGraphicsElement; clickListener: EventListener; keydownListener: EventListener; }> = []; + const missingTerritoryNodes: string[] = []; - for (const territoryId of Object.keys(state.territories)) { + for (const territoryId of territoryIds) { const path = getTerritoryNode(svgElement, territoryId); if (!path) { + missingTerritoryNodes.push(territoryId); continue; } - const clickListener = () => onSelectTerritory(territoryId); + const clickListener = (event: Event) => { + const target = event.target instanceof Element ? event.target.id : null; + // #region agent log + agentDebugLog({ + hypothesisId: "C", + location: "BoardMap.tsx:clickListener", + message: "Territory click listener fired", + data: { + territoryId, + targetId: target, + currentTargetId: path.id, + pointerEvents: path.style.pointerEvents || null, + fill: path.style.fill || null, + }, + }); + // #endregion + onSelectTerritory(territoryId); + }; const keydownListener = (event: Event) => { const keyboardEvent = event as KeyboardEvent; if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") { @@ -111,7 +155,41 @@ export function BoardMap({ handlers.push({ node: path, clickListener, keydownListener }); } + const svgClickListener = (event: Event) => { + const target = event.target instanceof Element ? event.target : null; + const targetId = target?.id ?? null; + if (targetId && territoryIdSet.has(targetId)) { + return; + } + // #region agent log + agentDebugLog({ + hypothesisId: "B", + location: "BoardMap.tsx:svgClickListener", + message: "SVG click landed outside a known territory id", + data: { + targetId, + targetTag: target?.tagName?.toLowerCase() ?? null, + }, + }); + // #endregion + }; + + svgElement.addEventListener("click", svgClickListener); + // #region agent log + agentDebugLog({ + hypothesisId: "A", + location: "BoardMap.tsx:attachHandlers", + message: "Attached territory interaction handlers", + data: { + expectedTerritories: territoryIds.length, + attachedHandlers: handlers.length, + missingTerritoryNodes, + }, + }); + // #endregion + return () => { + svgElement.removeEventListener("click", svgClickListener); for (const handler of handlers) { handler.node.removeEventListener("click", handler.clickListener); handler.node.removeEventListener("keydown", handler.keydownListener); @@ -133,10 +211,13 @@ export function BoardMap({ const playerById = new Map( state.players.map((player) => [player.id, player]), ); + let styledTerritories = 0; + const missingTerritoryNodes: string[] = []; for (const [territoryId, territory] of Object.entries(state.territories)) { const path = getTerritoryNode(svgElement, territoryId); if (!path) { + missingTerritoryNodes.push(territoryId); continue; } @@ -162,7 +243,20 @@ export function BoardMap({ selectedTerritoryId === territoryId ? "drop-shadow(0 0 7px rgba(246, 221, 157, 0.36))" : "none"; + styledTerritories += 1; } + // #region agent log + agentDebugLog({ + hypothesisId: "E", + location: "BoardMap.tsx:styleTerritories", + message: "Applied territory visual styles", + data: { + styledTerritories, + missingTerritoryNodes, + selectedTerritoryId, + }, + }); + // #endregion }, [selectedTerritoryId, state.players, state.territories]); const selectedTerritory = selectedTerritoryId diff --git a/apps/web/src/features/game/utils/agentDebugLog.ts b/apps/web/src/features/game/utils/agentDebugLog.ts new file mode 100644 index 0000000..66d15ce --- /dev/null +++ b/apps/web/src/features/game/utils/agentDebugLog.ts @@ -0,0 +1,24 @@ +const AGENT_DEBUG_PREFIX = "__agent_debug__"; + +export function agentDebugLog(payload: { + hypothesisId: string; + location: string; + message: string; + data?: Record; +}) { + if (typeof window === "undefined") { + return; + } + + try { + const entry = { + ...payload, + timestamp: Date.now(), + }; + console.info(`${AGENT_DEBUG_PREFIX}${JSON.stringify(entry)}`); + } catch { + // Swallow serialization errors to avoid impacting UI behavior during debug. + } +} + +export { AGENT_DEBUG_PREFIX }; From 121940ac69ed58b49133c29c4432c1e52e1cb6a6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 20:28:55 +0000 Subject: [PATCH 4/9] fix(web): restore map territory clicks during setup claim Co-authored-by: G9Pedro --- .../src/features/game/components/BoardMap.tsx | 17 ++++++++++++++--- apps/web/src/styles/global.css | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index c3ddf47..53fbc0c 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -348,7 +348,18 @@ export function BoardMap({ } function getTerritoryNode(svgElement: SVGSVGElement, territoryId: string) { - return svgElement.querySelector( - `#${CSS.escape(territoryId)}`, - ); + const candidateIds = [territoryId, ...(TERRITORY_ID_ALIASES[territoryId] ?? [])]; + for (const candidateId of candidateIds) { + const node = svgElement.querySelector( + `#${CSS.escape(candidateId)}`, + ); + if (node) { + return node; + } + } + return null; } + +const TERRITORY_ID_ALIASES: Record = { + yakutsk: ["yakursk"], +}; diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 70022b8..19032f4 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -534,10 +534,12 @@ button:disabled { display: grid; place-items: center; z-index: 100; + pointer-events: none; } .onboarding-card { width: min(560px, 90vw); + pointer-events: auto; } .winner-banner { From 28713ca2fd9ff8d41cf43c03ed1859a436e730c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 20:32:03 +0000 Subject: [PATCH 5/9] chore(debug): capture alaska style state in map logs Co-authored-by: G9Pedro --- apps/web/src/features/game/components/BoardMap.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index 53fbc0c..5846c4a 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -175,6 +175,7 @@ export function BoardMap({ }; svgElement.addEventListener("click", svgClickListener); + const alaskaNode = getTerritoryNode(svgElement, "alaska"); // #region agent log agentDebugLog({ hypothesisId: "A", @@ -184,6 +185,7 @@ export function BoardMap({ expectedTerritories: territoryIds.length, attachedHandlers: handlers.length, missingTerritoryNodes, + alaskaPointerEvents: alaskaNode?.style.pointerEvents ?? null, }, }); // #endregion @@ -245,6 +247,7 @@ export function BoardMap({ : "none"; styledTerritories += 1; } + const alaskaNode = getTerritoryNode(svgElement, "alaska"); // #region agent log agentDebugLog({ hypothesisId: "E", @@ -254,6 +257,7 @@ export function BoardMap({ styledTerritories, missingTerritoryNodes, selectedTerritoryId, + alaskaFill: alaskaNode?.style.fill ?? null, }, }); // #endregion From a545215ca36cd7445e8ee1ffa5579f40c124854c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 20:34:23 +0000 Subject: [PATCH 6/9] chore(debug): mark territories with handler binding flag Co-authored-by: G9Pedro --- apps/web/src/features/game/components/BoardMap.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index 5846c4a..e5be7af 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -144,6 +144,7 @@ export function BoardMap({ path.classList.add("territory-region"); path.setAttribute("tabindex", "0"); path.setAttribute("role", "button"); + path.setAttribute("data-agent-click-bound", "true"); path.setAttribute( "aria-label", `Select territory ${formatTerritoryLabel(territoryId)}`, From 292ba502116052d6c403091ec18aec7578e942a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 20:37:18 +0000 Subject: [PATCH 7/9] fix(web): reapply map handlers/styles every render Co-authored-by: G9Pedro --- apps/web/src/features/game/components/BoardMap.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index e5be7af..74e10f0 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -198,7 +198,7 @@ export function BoardMap({ handler.node.removeEventListener("keydown", handler.keydownListener); } }; - }, [svgMarkup, state.territories, onSelectTerritory]); + }); useEffect(() => { if (!containerRef.current) { @@ -262,7 +262,7 @@ export function BoardMap({ }, }); // #endregion - }, [selectedTerritoryId, state.players, state.territories]); + }); const selectedTerritory = selectedTerritoryId ? state.territories[selectedTerritoryId] From bef829915206dd24f46aebe625295a167ef60441 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 20:38:39 +0000 Subject: [PATCH 8/9] chore(debug): remove temporary map click instrumentation Co-authored-by: G9Pedro --- apps/web/src/features/game/GamePage.tsx | 16 --- .../src/features/game/components/BoardMap.tsx | 104 +----------------- .../src/features/game/utils/agentDebugLog.ts | 24 ---- 3 files changed, 3 insertions(+), 141 deletions(-) delete mode 100644 apps/web/src/features/game/utils/agentDebugLog.ts diff --git a/apps/web/src/features/game/GamePage.tsx b/apps/web/src/features/game/GamePage.tsx index 27ecd57..24fd5e5 100644 --- a/apps/web/src/features/game/GamePage.tsx +++ b/apps/web/src/features/game/GamePage.tsx @@ -9,7 +9,6 @@ import { CardsPanel } from "./components/CardsPanel"; import { BattleLogPanel } from "./components/BattleLogPanel"; import { OnboardingTour, shouldShowOnboarding } from "./components/OnboardingTour"; import { formatPhaseLabel, formatTerritoryLabel } from "./utils/labels"; -import { agentDebugLog } from "./utils/agentDebugLog"; interface GamePageProps { credentials: SessionCredentials; @@ -53,21 +52,6 @@ export function GamePage({ credentials, onLeaveSession }: GamePageProps) { void refreshLegalActions(credentials); }, [credentials, privateState, refreshLegalActions]); - useEffect(() => { - // #region agent log - agentDebugLog({ - hypothesisId: "D", - location: "GamePage.tsx:selectedTerritoryEffect", - message: "Selected territory state changed", - data: { - selectedTerritoryId, - hasPublicState: Boolean(publicState), - hasPrivateState: Boolean(privateState), - }, - }); - // #endregion - }, [privateState, publicState, selectedTerritoryId]); - const isMyTurn = useMemo( () => privateState?.currentPlayerId === credentials.playerId, [credentials.playerId, privateState], diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index 74e10f0..c6676e6 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react"; import type { GameStatePublic } from "@risk/shared-types"; import { formatTerritoryLabel } from "../utils/labels"; import { TerritoryLayer } from "./TerritoryLayer"; -import { agentDebugLog } from "../utils/agentDebugLog"; interface BoardMapProps { state: GameStatePublic; @@ -22,30 +21,8 @@ export function BoardMap({ useEffect(() => { fetch("/assets/board/risk-board.svg") .then((response) => response.text()) - .then((text) => { - setSvgMarkup(text); - // #region agent log - agentDebugLog({ - hypothesisId: "A", - location: "BoardMap.tsx:fetchSvg.then", - message: "Loaded board SVG markup", - data: { - markupLength: text.length, - }, - }); - // #endregion - }) - .catch(() => { - setSvgMarkup(""); - // #region agent log - agentDebugLog({ - hypothesisId: "A", - location: "BoardMap.tsx:fetchSvg.catch", - message: "Failed to load board SVG markup", - data: {}, - }); - // #endregion - }); + .then((text) => setSvgMarkup(text)) + .catch(() => setSvgMarkup("")); }, []); useEffect(() => { @@ -100,39 +77,19 @@ export function BoardMap({ } const territoryIds = Object.keys(state.territories); - const territoryIdSet = new Set(territoryIds); const handlers: Array<{ node: SVGGraphicsElement; clickListener: EventListener; keydownListener: EventListener; }> = []; - const missingTerritoryNodes: string[] = []; for (const territoryId of territoryIds) { const path = getTerritoryNode(svgElement, territoryId); if (!path) { - missingTerritoryNodes.push(territoryId); continue; } - const clickListener = (event: Event) => { - const target = event.target instanceof Element ? event.target.id : null; - // #region agent log - agentDebugLog({ - hypothesisId: "C", - location: "BoardMap.tsx:clickListener", - message: "Territory click listener fired", - data: { - territoryId, - targetId: target, - currentTargetId: path.id, - pointerEvents: path.style.pointerEvents || null, - fill: path.style.fill || null, - }, - }); - // #endregion - onSelectTerritory(territoryId); - }; + const clickListener = () => onSelectTerritory(territoryId); const keydownListener = (event: Event) => { const keyboardEvent = event as KeyboardEvent; if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") { @@ -144,7 +101,6 @@ export function BoardMap({ path.classList.add("territory-region"); path.setAttribute("tabindex", "0"); path.setAttribute("role", "button"); - path.setAttribute("data-agent-click-bound", "true"); path.setAttribute( "aria-label", `Select territory ${formatTerritoryLabel(territoryId)}`, @@ -156,43 +112,7 @@ export function BoardMap({ handlers.push({ node: path, clickListener, keydownListener }); } - const svgClickListener = (event: Event) => { - const target = event.target instanceof Element ? event.target : null; - const targetId = target?.id ?? null; - if (targetId && territoryIdSet.has(targetId)) { - return; - } - // #region agent log - agentDebugLog({ - hypothesisId: "B", - location: "BoardMap.tsx:svgClickListener", - message: "SVG click landed outside a known territory id", - data: { - targetId, - targetTag: target?.tagName?.toLowerCase() ?? null, - }, - }); - // #endregion - }; - - svgElement.addEventListener("click", svgClickListener); - const alaskaNode = getTerritoryNode(svgElement, "alaska"); - // #region agent log - agentDebugLog({ - hypothesisId: "A", - location: "BoardMap.tsx:attachHandlers", - message: "Attached territory interaction handlers", - data: { - expectedTerritories: territoryIds.length, - attachedHandlers: handlers.length, - missingTerritoryNodes, - alaskaPointerEvents: alaskaNode?.style.pointerEvents ?? null, - }, - }); - // #endregion - return () => { - svgElement.removeEventListener("click", svgClickListener); for (const handler of handlers) { handler.node.removeEventListener("click", handler.clickListener); handler.node.removeEventListener("keydown", handler.keydownListener); @@ -214,13 +134,10 @@ export function BoardMap({ const playerById = new Map( state.players.map((player) => [player.id, player]), ); - let styledTerritories = 0; - const missingTerritoryNodes: string[] = []; for (const [territoryId, territory] of Object.entries(state.territories)) { const path = getTerritoryNode(svgElement, territoryId); if (!path) { - missingTerritoryNodes.push(territoryId); continue; } @@ -246,22 +163,7 @@ export function BoardMap({ selectedTerritoryId === territoryId ? "drop-shadow(0 0 7px rgba(246, 221, 157, 0.36))" : "none"; - styledTerritories += 1; } - const alaskaNode = getTerritoryNode(svgElement, "alaska"); - // #region agent log - agentDebugLog({ - hypothesisId: "E", - location: "BoardMap.tsx:styleTerritories", - message: "Applied territory visual styles", - data: { - styledTerritories, - missingTerritoryNodes, - selectedTerritoryId, - alaskaFill: alaskaNode?.style.fill ?? null, - }, - }); - // #endregion }); const selectedTerritory = selectedTerritoryId diff --git a/apps/web/src/features/game/utils/agentDebugLog.ts b/apps/web/src/features/game/utils/agentDebugLog.ts deleted file mode 100644 index 66d15ce..0000000 --- a/apps/web/src/features/game/utils/agentDebugLog.ts +++ /dev/null @@ -1,24 +0,0 @@ -const AGENT_DEBUG_PREFIX = "__agent_debug__"; - -export function agentDebugLog(payload: { - hypothesisId: string; - location: string; - message: string; - data?: Record; -}) { - if (typeof window === "undefined") { - return; - } - - try { - const entry = { - ...payload, - timestamp: Date.now(), - }; - console.info(`${AGENT_DEBUG_PREFIX}${JSON.stringify(entry)}`); - } catch { - // Swallow serialization errors to avoid impacting UI behavior during debug. - } -} - -export { AGENT_DEBUG_PREFIX }; From 97dbbaef1ef19e5cd41310ca68a92ad729b301ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 07:40:31 +0000 Subject: [PATCH 9/9] feat(web): switch to civ-style map overlay command HUD Co-authored-by: G9Pedro --- apps/web/src/features/game/GamePage.tsx | 101 +++++----- .../src/features/game/components/BoardMap.tsx | 12 +- apps/web/src/styles/global.css | 190 +++++++++++++----- 3 files changed, 205 insertions(+), 98 deletions(-) diff --git a/apps/web/src/features/game/GamePage.tsx b/apps/web/src/features/game/GamePage.tsx index 24fd5e5..b8e4514 100644 --- a/apps/web/src/features/game/GamePage.tsx +++ b/apps/web/src/features/game/GamePage.tsx @@ -104,58 +104,62 @@ export function GamePage({ credentials, onLeaveSession }: GamePageProps) { return (
    -
    -
    -

    Lobby {credentials.lobbyCode}

    -

    Global Command

    -

    - {isMyTurn - ? "Your offensive window is open." - : "Monitoring allied commanders."}{" "} - · Phase: {formatPhaseLabel(privateState.currentPhase)} -

    -
    -
    - - -
    -
    - -
    +
    -
    +
    + + +
    +
+ +
+

Commander Snapshot

+

+ You control {myPublicPlayer?.territories ?? 0} territories + and hold {privateState.me.cards.length} cards. +

+

+ Territory focus:{" "} + + {selectedTerritoryId + ? formatTerritoryLabel(selectedTerritoryId) + : "No territory selected"} + +

+

+ Reinforcements in reserve: {privateState.me.reinforcementPool} +

+
+ + - -
- -
+
+ +
+ +

Legal Action Hints

{legalActions.length === 0 ? (

No legal actions available for the current phase.

diff --git a/apps/web/src/features/game/components/BoardMap.tsx b/apps/web/src/features/game/components/BoardMap.tsx index c6676e6..634aa71 100644 --- a/apps/web/src/features/game/components/BoardMap.tsx +++ b/apps/web/src/features/game/components/BoardMap.tsx @@ -37,7 +37,7 @@ export function BoardMap({ } const updateCenters = () => { - const svgRect = svgElement.getBoundingClientRect(); + const containerRect = root.getBoundingClientRect(); const nextCenters: Record = {}; for (const territoryId of Object.keys(state.territories)) { const path = getTerritoryNode(svgElement, territoryId); @@ -47,8 +47,14 @@ export function BoardMap({ const rect = path.getBoundingClientRect(); nextCenters[territoryId] = { - x: ((rect.left + rect.width / 2 - svgRect.left) / svgRect.width) * 100, - y: ((rect.top + rect.height / 2 - svgRect.top) / svgRect.height) * 100, + x: + ((rect.left + rect.width / 2 - containerRect.left) / + containerRect.width) * + 100, + y: + ((rect.top + rect.height / 2 - containerRect.top) / + containerRect.height) * + 100, }; } setCenters(nextCenters); diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 19032f4..eed674b 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -174,10 +174,8 @@ button:disabled { } .war-room-layout { - width: min(1880px, 98vw); - margin: 0.75rem auto 1.2rem; - display: grid; - gap: 0.85rem; + width: min(1920px, 99.3vw); + margin: 0.35rem auto 0.7rem; } .war-room-topbar h1 { @@ -198,35 +196,88 @@ button:disabled { font-weight: 600; } -.war-room-stage { - display: grid; - grid-template-columns: minmax(760px, 1fr) minmax(340px, 430px); - gap: 0.85rem; - align-items: start; +.war-room-overlay-stage { + position: relative; + min-height: calc(100vh - 0.9rem); + border-radius: 20px; + overflow: hidden; + box-shadow: 0 18px 44px rgba(3, 8, 13, 0.55); +} + +.overlay-topbar { + position: absolute; + top: 0.8rem; + left: 0.8rem; + right: 0.8rem; + z-index: 12; + pointer-events: auto; + background: + linear-gradient(170deg, rgba(185, 226, 255, 0.18), rgba(7, 15, 23, 0.66)), + rgba(4, 10, 17, 0.78); + border-color: rgba(182, 221, 247, 0.36); +} + +.overlay-commander-panel { + position: absolute; + top: 7.15rem; + left: 1rem; + width: min(340px, 27vw); + z-index: 11; + pointer-events: auto; } -.command-dock { - max-height: calc(100vh - 170px); +.overlay-command-stack { + position: absolute; + top: 7.15rem; + right: 1rem; + width: min(390px, 31vw); + max-height: calc(100vh - 18rem); overflow: auto; - padding-right: 0.15rem; + padding-right: 0.1rem; + z-index: 11; + pointer-events: auto; } -.war-room-feed { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(340px, 430px); - gap: 0.85rem; +.overlay-battle-log { + position: absolute; + left: 1rem; + bottom: 1rem; + width: min(500px, 42vw); + z-index: 11; + pointer-events: auto; +} + +.overlay-legal-intel { + position: absolute; + right: 1rem; + bottom: 1rem; + width: min(390px, 31vw); + max-height: 30vh; + overflow: auto; + z-index: 11; + pointer-events: auto; +} + +.overlay-command-stack .panel, +.overlay-commander-panel, +.overlay-battle-log .panel, +.overlay-legal-intel { + background: + linear-gradient(170deg, rgba(192, 230, 255, 0.12), rgba(5, 14, 22, 0.7)), + var(--panel-strong); + border-color: rgba(177, 215, 241, 0.34); + box-shadow: 0 10px 28px rgba(2, 7, 12, 0.54); } .board-shell { - min-height: 74vh; + min-height: 100%; } .board-container { position: relative; width: 100%; - min-height: 74vh; - aspect-ratio: 16 / 10; - border-radius: 18px; + min-height: calc(100vh - 0.9rem); + border-radius: 20px; overflow: hidden; background: radial-gradient(circle at 34% 20%, rgba(72, 120, 156, 0.32), rgba(9, 17, 27, 0.96) 70%), @@ -271,13 +322,20 @@ button:disabled { position: absolute; inset: 0; z-index: 1; + overflow: hidden; } .risk-board-svg svg { - width: 100%; - height: 100%; + position: absolute; + left: 50%; + top: 50.5%; + width: 126%; + height: 126%; + max-width: none; + max-height: none; display: block; - filter: saturate(1.16) contrast(1.1) brightness(0.94); + transform: translate(-50%, -50%); + filter: saturate(1.18) contrast(1.14) brightness(0.95); } .territory-region { @@ -340,18 +398,17 @@ button:disabled { } .map-hud-top-left { - top: 1rem; - left: 1rem; + display: none; } .map-hud-top-right { - top: 1rem; - right: 1rem; + top: 7.4rem; + right: calc(min(31vw, 390px) + 1.8rem); + width: min(260px, 21vw); } .map-hud-bottom-left { - left: 1rem; - bottom: 1rem; + display: none; } .hud-kicker { @@ -516,6 +573,20 @@ button:disabled { min-height: 240px; } +.overlay-battle-log .battle-log-panel { + min-height: 24vh; + max-height: 30vh; + overflow: hidden; +} + +.overlay-battle-log .battle-log-list { + max-height: calc(30vh - 4rem); +} + +.overlay-legal-intel.legal-intel-panel { + min-height: 0; +} + .legal-list { margin: 0; padding-left: 1rem; @@ -547,28 +618,59 @@ button:disabled { font-weight: 700; } -@media (max-width: 1360px) { - .war-room-stage { - grid-template-columns: minmax(0, 1fr) minmax(310px, 390px); +@media (max-width: 1480px) { + .overlay-commander-panel { + width: min(320px, 29vw); + } + + .overlay-command-stack, + .overlay-legal-intel { + width: min(360px, 32vw); } - .war-room-feed { - grid-template-columns: 1fr; + .overlay-battle-log { + width: min(460px, 42vw); + } + + .map-hud-top-right { + right: calc(min(32vw, 360px) + 1.8rem); + width: min(230px, 20vw); } } -@media (max-width: 1080px) { - .war-room-stage { - grid-template-columns: 1fr; +@media (max-width: 1220px) { + .war-room-overlay-stage { + display: grid; + gap: 0.75rem; + min-height: unset; + overflow: visible; + box-shadow: none; } - .command-dock { + .overlay-topbar, + .overlay-commander-panel, + .overlay-command-stack, + .overlay-battle-log, + .overlay-legal-intel { + position: static; + width: 100%; max-height: unset; + } + + .overlay-command-stack, + .overlay-battle-log, + .overlay-legal-intel { overflow: visible; } .board-container { - min-height: 62vh; + min-height: 68vh; + border-radius: 18px; + } + + .risk-board-svg svg { + width: 116%; + height: 116%; } .map-hud-top-right { @@ -592,13 +694,7 @@ button:disabled { max-width: calc(100% - 2rem); } - .map-hud-bottom-left { - bottom: 0.75rem; - left: 0.75rem; - } - - .map-hud-top-left { - top: 0.75rem; - left: 0.75rem; + .board-container { + min-height: 60vh; } }