diff --git a/apps/mapgen-studio/.interface-design/system.md b/apps/mapgen-studio/.interface-design/system.md index 5131f1af41..8af921962c 100644 --- a/apps/mapgen-studio/.interface-design/system.md +++ b/apps/mapgen-studio/.interface-design/system.md @@ -214,6 +214,8 @@ Decisions (see `docs/projects/mapgen-studio-redesign/pass-4-design-fixes.md`): Console glyphs: autoplay start `FastForward` / stop `Square`; Run in Game `SquareArrowOutUpRight` (launches the external app); Explore `Binoculars` (tile visibility). `Eye` stays reserved for layer-visibility toggles. + *(Glyph assignments superseded by the Z-wave registry v3 below; the + icon-only contract itself is unchanged.)* - **Config objects collapse:** every config object renders a per-object header row (chevron + title + trailing action zone — future home of object-local Reset/Show-JSON); collapsed by default, manual expand; optional sticky @@ -318,3 +320,34 @@ Decisions (see `openspec/changes/mapgen-studio-dag-tab/design.md`): the options it specifies + its player options; ungoverned keys never count as drift. The game-setup dropdowns may never silently supersede a selected saved config. + +## Z-wave amendment (2026-06-12, user-grounded): Game bar v3 — one status, one popup, launch CTA + +Rearranges the Game bar (supersedes the Pass-4/5 console glyph registry and +the trailing-disclosure placement; keeps the icon-only contract + zoning): + +- **Bar order:** `Gamepad2` Game · │ · Config select + Modified pill + + **gear** (`Settings`, icon-only Button) · │ · signal chip · autoplay · + explore · **Play** CTA. The game-setup disclosure rides the CONFIG cluster + (the setup it opens is exactly what drifts a saved config); open state = + `ring-1 ring-ring border-primary text-primary` (the footer auto-run idiom). +- **ONE status, held by the signal chip.** The chip's dot folds live runtime + + Run in Game + save/deploy into one color (any failure → destructive; + in-flight or stale → warning; live ok → success); while an operation runs + the chip text narrates its phase, then settles back to `Turn N · Seed S`. + No secondary status pills ride the bar — the run-relation pill + (Current/Stale/Previous), request ids, errors, and recovery hints live in + the **status hang-off**: a popup panel docked under the chip (`popover` + tier, chevron-disclosed, outside-click + Escape dismissal — popup + semantics keep it from ever stacking against the setup row). Secondary + per-operation affordances live inside it: Apply-live-to-Studio, refresh + status (`RotateCw`), copy diagnostics (**`Bug`** — it's the debug action). +- **Console glyph registry (v3):** autoplay idle = composed glyph (one + `Play` triangle inside the clockwise `RotateCw` loop — "keep playing + forward"); stop `Square` unchanged. Explore = **`ScanEye`** (reveal/vision + without the toy binoculars; bare `Eye` stays reserved for layer-visibility + toggles). Run in Game = labeled **`Play`** CTA, filled primary, SAME + Button size as the World console's Run (the two bars mirror: World "Run" + authors the map, Game "Play" launches it) with a **`Rocket`** glyph — + launch-the-external-app semantics, replacing `SquareArrowOutUpRight`. + `SlidersHorizontal` retired from the header (gear owns setup). diff --git a/apps/mapgen-studio/src/ui/components/AppHeader.tsx b/apps/mapgen-studio/src/ui/components/AppHeader.tsx index 9025ccdc2e..a48b9a4f63 100644 --- a/apps/mapgen-studio/src/ui/components/AppHeader.tsx +++ b/apps/mapgen-studio/src/ui/components/AppHeader.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { ChevronDown, Gamepad2, SlidersHorizontal } from 'lucide-react'; +import { Gamepad2, Settings } from 'lucide-react'; import { AppBrand } from './AppBrand'; import { ViewControls } from './ViewControls'; import { OptionSelect } from './OptionSelect'; +import { Button, Tooltip, TooltipContent, TooltipTrigger } from '../../components/ui'; import { getLocalPlayerSetup, updateStudioSetupGameOption, @@ -61,8 +62,6 @@ export const AppHeader: React.FC = ({ const textSecondary = 'text-muted-foreground'; const textMuted = 'text-muted-foreground/70'; const dividerColor = 'bg-border'; - const setupButtonClassName = - 'flex h-7 shrink-0 items-center gap-1.5 rounded border border-input bg-input-background px-2 text-data font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'; const localPlayerSetup = getLocalPlayerSetup(setupConfig); const updateLeader = (value: string) => { onSetupConfigChange(updateStudioSetupPlayerOption(setupConfig, 'PlayerLeader', value || undefined)); @@ -138,6 +137,25 @@ export const AppHeader: React.FC = ({ Modified ) : null} + {/* Game-setup disclosure: the gear rides the config cluster (the + setup it opens is what drifts a saved config), icon-only per + the console contract. */} + + + + + Game setup + {gameConsole ? ( @@ -146,22 +164,6 @@ export const AppHeader: React.FC = ({ {gameConsole} ) : null} - -
- - {/* Game-setup disclosure: icon-only (the dropdown row IS the - label), pinned last in the bar per the Pass-5 spec. */} -
{setupOpen ? ( diff --git a/apps/mapgen-studio/src/ui/components/GameConsole.tsx b/apps/mapgen-studio/src/ui/components/GameConsole.tsx index efa540adb0..31321ae950 100644 --- a/apps/mapgen-studio/src/ui/components/GameConsole.tsx +++ b/apps/mapgen-studio/src/ui/components/GameConsole.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Binoculars, Clipboard, FastForward, LoaderCircle, Radio, RotateCw, Square, SquareArrowOutUpRight } from 'lucide-react'; +import { Bug, ChevronDown, LoaderCircle, Play, Radio, Rocket, RotateCw, ScanEye, Square } from 'lucide-react'; import { Button, Tooltip, TooltipContent, TooltipTrigger } from '../../components/ui'; import { formatRunInGamePhaseLabel, @@ -16,16 +16,20 @@ import { // ============================================================================ // GAME CONSOLE // ============================================================================ -// The Game bar's command cluster (Pass-5 toolbar-architecture-v2 spec): every -// control and readout that observes or commands the LIVE GAME lives here — -// live runtime chip + apply-suggestion bridge, autoplay, Run in Game with its -// status/retry/diagnostics, and the save-deploy chip. New live-game controls -// belong in this unit, not in the studio console. The cluster renders inline -// with NO panel chrome or identity label of its own: AppHeader composes it -// into the Game bar row, whose "Game" identity covers it. The studio↔game -// bridge cues (stale warning ring, Current/Stale/Previous relation chip) are -// computed against the current authored Studio state and surface here, beside -// the controls they qualify. +// The Game bar's command cluster (Pass-5 toolbar-architecture-v2 spec; Z-wave +// status consolidation): every control and readout that observes or commands +// the LIVE GAME lives here. The cluster is ONE status + THREE commands: +// +// [signal chip: dot + turn/seed + chevron] [autoplay] [explore] [Play] +// +// The chip is the single merged game status — its dot folds the live runtime, +// the Run in Game operation, and save/deploy into one color, and clicking it +// opens the status hang-off: a panel docked under the bar (same idiom as the +// header's setup disclosure) carrying the expanded per-operation statuses and +// their secondary affordances (apply-live-to-Studio, refresh status, copy +// diagnostics as the Bug action). Nothing else in the bar pulses or stacks +// pills; the studio↔game bridge cues (stale warning ring, Current/Stale/ +// Previous relation) surface on the chip and inside the hang-off. // ============================================================================ /** Read-only live Civ7 runtime snapshot the console renders. */ @@ -72,8 +76,22 @@ export interface GameConsoleProps { onCopyRunInGameDiagnostics?: () => void; /** Current config save/deploy status */ saveDeployStatus?: MapConfigSaveDeployStatus | null; + /** Initial open state for the status hang-off (tests + dev affordance). */ + defaultStatusOpen?: boolean; } +/** + * Autoplay glyph: one play triangle wrapped by the clockwise repeat loop — + * "keep playing forward". Composed from lucide parts because no single glyph + * carries both. The `!` overrides beat the Button's `[&_svg]:size-3.5` rule. + */ +const AutoplayGlyph: React.FC = () => ( + +); + export const GameConsole: React.FC = ({ liveRuntime, liveGameStudioRelation = "unknown", @@ -89,9 +107,33 @@ export const GameConsole: React.FC = ({ onRunInGameRetryStatus, onCopyRunInGameDiagnostics, saveDeployStatus, + defaultStatusOpen = false, }) => { + const [statusOpen, setStatusOpen] = React.useState(defaultStatusOpen); + const rootRef = React.useRef(null); + // The hang-off is a popup, so it dismisses like one (outside click / + // Escape). This also keeps it from ever stacking against the header's + // setup-disclosure row — opening anything else IS an outside click. + React.useEffect(() => { + if (!statusOpen) return; + const onPointerDown = (event: PointerEvent) => { + if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) { + setStatusOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setStatusOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [statusOpen]); const textPrimary = 'text-foreground'; const textMuted = 'text-muted-foreground/70'; + const eyebrowClass = `text-label font-medium uppercase tracking-wider ${textMuted}`; // The "stale vs live game" emphasis is a warning about data, so it uses the // `warning` token (not the slate identity accent). const liveDotClass = @@ -113,12 +155,14 @@ export const GameConsole: React.FC = ({ ? "Current" : "Previous" : null; + const runInGameFailed = + runInGameStatus?.status === "failed" || runInGameStatus?.status === "blocked" || runInGameStatus?.status === "uncertain"; const runInGameDotClass = runInGameCurrentRelation === "stale" ? "bg-warning" : runInGameStatus?.status === "complete" ? "bg-success" - : runInGameStatus?.status === "failed" || runInGameStatus?.status === "blocked" || runInGameStatus?.status === "uncertain" + : runInGameFailed ? "bg-destructive" : isRunInGameRunning ? "bg-warning" @@ -129,9 +173,6 @@ export const GameConsole: React.FC = ({ liveGameStudioRelation === "stale" && Boolean(onSyncFromLiveGame) && !operationControlsDisabled; - const liveSyncTitle = liveSyncAvailable - ? "Apply live game suggestion to Studio" - : liveRuntime?.readiness ?? liveRuntime?.error ?? "Civ7 live runtime status"; const autoplayControlDisabled = operationControlsDisabled || isAutoplayActionRunning || liveRuntime?.status !== "ok" || !onToggleAutoplay; // Icon-only contract (Pass-4): the start/stop/in-flight wording the label // used to carry lives entirely in the accessible name + tooltip. @@ -144,16 +185,46 @@ export const GameConsole: React.FC = ({ ? "Explore: toggle tile visibility in the live game" : "Explore: tile visibility control is not yet available"; const saveDeployLabel = saveDeployStatus ? formatMapConfigSaveDeployPhaseLabel(saveDeployStatus.phase) : null; - const saveDeployTitle = saveDeployStatus - ? [ - `Save/Deploy: ${saveDeployLabel}`, - saveDeployStatus.requestId ? `Request: ${saveDeployStatus.requestId}` : null, - saveDeployStatus.path ? `Path: ${saveDeployStatus.path}` : null, - saveDeployStatus.error ? `Error: ${saveDeployStatus.error}` : null, - ].filter(Boolean).join("\n") - : "Config save/deploy status"; - // Icon-only contract (Pass-4): the dynamic action label ("Run in Game", - // "Retry Run", "Restart Civ & Run") leads the accessible name + tooltip. + const saveDeployActive = saveDeployStatus?.status === "running"; + const saveDeployDotClass = + saveDeployStatus?.status === "failed" + ? "bg-destructive" + : saveDeployStatus?.status === "complete" + ? "bg-success" + : "bg-warning"; + // The chip's ONE dot folds every tracked operation into a single color: + // any failure wins, then in-flight/stale warnings, then live health. + const combinedDotClass = + liveRuntime?.status === "error" || runInGameFailed || saveDeployStatus?.status === "failed" + ? "bg-destructive" + : isRunInGameRunning || + saveDeployActive || + runInGameCurrentRelation === "stale" || + liveGameStudioRelation === "stale" + ? "bg-warning" + : liveRuntime?.status === "ok" + ? "bg-success" + : "bg-muted-foreground"; + // Activity overrides the idle turn/seed readout: while something is in + // flight the chip narrates the phase, then settles back to the live line. + const chipText = isRunInGameRunning + ? runInGamePhaseLabel + : saveDeployActive + ? saveDeployLabel ?? liveText + : liveText; + const chipTitle = [ + `Live: ${liveText}`, + liveRuntime?.autoplayActive ? `Autoplay active${liveRuntime.autoplayPaused ? " (paused)" : ""}` : null, + liveGameStudioRelation === "stale" ? "Live game is ahead of Studio — open for the apply action" : null, + runInGameStatus ? `Run in Game: ${runInGamePhaseLabel}` : null, + runInGameStateLabel ? `Studio state: ${runInGameStateLabel}` : null, + runInGameStatus?.requestId ? `Request: ${runInGameStatus.requestId}` : null, + saveDeployStatus ? `Save/Deploy: ${saveDeployLabel}` : null, + saveDeployStatus?.requestId ? `Deploy request: ${saveDeployStatus.requestId}` : null, + "Click to expand game status", + ].filter(Boolean).join("\n"); + // The labeled CTA keeps the full operation story in its accessible name: + // dynamic action ("Run in Game", "Retry Run", "Restart Civ & Run") first. const runInGameTitle = [ runInGameButtonText, runInGameStatus ? `Run in Game: ${runInGamePhaseLabel}` : "Launches the current config in Civ7", @@ -163,36 +234,41 @@ export const GameConsole: React.FC = ({ runInGameStatus?.error ? `Error: ${runInGameStatus.error}` : null, runInGameStatus?.details?.recoveryHint ? `Recovery: ${runInGameStatus.details.recoveryHint}` : null, ].filter(Boolean).join("\n"); + const applyLiveTitle = "Apply live game suggestion to Studio"; return ( -
+
- {liveSyncTitle} + {chipTitle} @@ -211,7 +287,7 @@ export const GameConsole: React.FC = ({ ) : liveRuntime?.autoplayActive ? ( ) : ( - + )} @@ -229,97 +305,152 @@ export const GameConsole: React.FC = ({ title={exploreTitle} className="shrink-0"> - + {exploreTitle} - {saveDeployStatus && saveDeployStatus.status !== "complete" ? ( - - -
+ {/* The Game CTA mirrors the World console's Run: the one filled action + in its bar, same Button size, verb label. Rocket = launch Civ7 (the + action leaves the studio — the old external-launch semantic). */} + + + {statusOpen ? ( +
-
- {runInGamePhaseLabel} - {runInGameStateLabel ? ( - - {runInGameStateLabel} - +
+ {/* Live runtime */} +
+
+ Live game + {liveRuntime?.autoplayActive ? ( + + Auto{liveRuntime.autoplayPaused ? " (paused)" : ""} + + ) : null} +
+
+ + {liveText} +
+ {liveRuntime?.readiness && liveRuntime.status === "ok" && (liveRuntime.turn !== undefined || liveRuntime.seed !== undefined) ? ( + {liveRuntime.readiness} + ) : null} + {liveSyncAvailable ? ( + ) : null}
- - {runInGameTitle} - - ) : null} - {runInGameStatus && onRunInGameRetryStatus && runInGameCanRetryStatus(runInGameStatus) ? ( - - - - - Refresh Run in Game status - - ) : null} - {runInGameStatus && onCopyRunInGameDiagnostics ? ( - - - - - Copy Run in Game diagnostics - - ) : null} - - - + + Refresh Run in Game status + + ) : null} + {runInGameStatus && onCopyRunInGameDiagnostics ? ( + + + - - {runInGameTitle} - + + + + Copy Run in Game diagnostics + + ) : null} +
+
+ {runInGameStatus ? ( + <> +
+ + {runInGamePhaseLabel} + {runInGameStateLabel ? ( + + {runInGameStateLabel} + + ) : null} +
+ + {runInGameStatus.requestId} + + {runInGameStatus.error ? ( +

{runInGameStatus.error}

+ ) : null} + {runInGameStatus.details?.recoveryHint ? ( +

{runInGameStatus.details.recoveryHint}

+ ) : null} + + ) : ( + No run yet + )} +
+ + {/* Save & deploy operation */} + {saveDeployStatus ? ( +
+ Save & Deploy +
+ + {saveDeployLabel} +
+ {saveDeployStatus.requestId ? ( + + {saveDeployStatus.requestId} + + ) : null} + {saveDeployStatus.error ? ( +

{saveDeployStatus.error}

+ ) : null} +
+ ) : null} +
+
+ ) : null}
); }; diff --git a/apps/mapgen-studio/test/runInGame/GameConsole.test.tsx b/apps/mapgen-studio/test/runInGame/GameConsole.test.tsx index fd6de42957..20295ceef3 100644 --- a/apps/mapgen-studio/test/runInGame/GameConsole.test.tsx +++ b/apps/mapgen-studio/test/runInGame/GameConsole.test.tsx @@ -6,9 +6,11 @@ import { TooltipProvider } from "../../src/components/ui/tooltip"; import type { RunInGameOperationStatus } from "../../src/features/runInGame/status"; // The game console owns all live-Civ7 markup (Pass-5 toolbar-architecture-v2 -// spec: it renders as the command cluster inside the header's Game bar). +// spec: it renders as the command cluster inside the header's Game bar; the +// Z-wave folded the status pills into ONE chip + the status hang-off panel). // These scenarios moved here from AppFooter.test.tsx when the console left -// the footer. +// the footer. Expanded-status pins render with `defaultStatusOpen` because +// static markup cannot click the chip open. function renderConsole(overrides: Partial = {}) { return renderToStaticMarkup( @@ -18,6 +20,7 @@ function renderConsole(overrides: Partial = {}) { isRunInGameRunning={false} onRunInGame={vi.fn()} liveRuntime={{ status: "ok", readiness: "shell" }} + defaultStatusOpen {...overrides} /> @@ -196,3 +199,67 @@ describe("GameConsole live runtime and save/deploy", () => { expect(html).toContain("Seed 123"); }); }); + +describe("GameConsole combined status chip + hang-off (Z-wave)", () => { + it("keeps the hang-off closed by default: secondary affordances stay off the bar", () => { + const html = renderConsole({ + defaultStatusOpen: false, + runInGameStatus: { + ok: true, + requestId: "studio-run-in-game-closed", + phase: "complete", + status: "complete", + startedAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:01.000Z", + completedPhases: ["materializing", "deploying"], + }, + onCopyRunInGameDiagnostics: vi.fn(), + }); + + expect(html).toContain('aria-expanded="false"'); + expect(html).not.toContain("Copy Run in Game diagnostics"); + // The chip's title still aggregates the merged status for hover/AT. + expect(html).toContain("studio-run-in-game-closed"); + }); + + it("renders the labeled Play CTA, swapping to Playing... while a run is in flight", () => { + const idle = renderConsole({ defaultStatusOpen: false }); + expect(idle).toContain(">Play<"); + + const running = renderConsole({ + defaultStatusOpen: false, + isRunInGameRunning: true, + runInGameStatus: { + ok: true, + requestId: "studio-run-in-game-running", + phase: "deploying", + status: "running", + startedAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:01.000Z", + completedPhases: ["materializing"], + }, + }); + expect(running).toContain("Playing..."); + // Activity narration: the chip text carries the in-flight phase. + expect(running).toContain(">Deploying<"); + }); + + it("folds operation failures into the chip's single status dot", () => { + // Hang-off closed: the only status dot in the markup is the chip's. + const html = renderConsole({ + defaultStatusOpen: false, + runInGameStatus: { + ok: false, + requestId: "studio-run-in-game-folded", + phase: "failed", + status: "failed", + startedAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:01.000Z", + completedPhases: ["materializing"], + error: "deploy exploded", + }, + }); + + expect(html).toContain("bg-destructive"); + }); +}); diff --git a/docs/projects/mapgen-studio-redesign/00-GOAL.md b/docs/projects/mapgen-studio-redesign/00-GOAL.md index eefcbca6f7..55f997f5a1 100644 --- a/docs/projects/mapgen-studio-redesign/00-GOAL.md +++ b/docs/projects/mapgen-studio-redesign/00-GOAL.md @@ -507,3 +507,41 @@ addressed to this lane inter-item margin; expansion opens a recessed `surface-sunken` slab (door INTO the graphite); wells keep their one tier inside. Verified live, dark + light. system.md Y-wave amendment carries all three design rules. + +## Studio fixes wave Z (2026-06-12, user-directed) — Game bar v3 + +Six rapid, craft-attuned toolbar changes on the Game bar (GameConsole + +AppHeader), committed as `design/z1-game-toolbar-v3`: + +- **Z1a gear:** the game-setup disclosure became a `Settings` gear icon + Button riding the CONFIG cluster (right of the selector + Modified pill, + before the divider into the console) — the setup it opens is exactly what + drifts a saved config. `SlidersHorizontal` + chevron retired. +- **Z1b one status:** the run-in-game phase chip + relation pill and the + save/deploy chip left the bar; the signal chip now holds THE combined + status (dot folds live + run + deploy; text narrates the active phase + while one runs). Clicking the chip opens the **status hang-off** — a + popover-tier panel under the bar with per-operation sections (Live game / + Run in Game / Save & Deploy), request ids, errors, recovery hints, and the + relocated secondary affordances. Popup semantics (outside-click + Escape + dismissal) — also what prevents stacking against the setup row (the bar's + backdrop-blur makes z-index local, so simultaneous-open had overlapped). +- **Z1c debug action:** Copy-diagnostics moved into the hang-off as the + `Bug` ghost button (with `RotateCw` refresh beside it when retryable). +- **Z1d explore glyph:** `Binoculars` → `ScanEye` (vision/reveal, non-toy; + `Eye` stays reserved for layer visibility). +- **Z1e autoplay glyph:** `FastForward` → composed play-in-clockwise-loop + (small filled `Play` centered in `RotateCw`; `!` size overrides beat the + Button's svg sizing rule). Stop remains `Square`. +- **Z1f Play CTA:** Run in Game became the bar's one filled action — label + "Play" ("Playing..." in flight), `Rocket` glyph (launch-the-external-app + semantics), exact Button-size parity with the World console's Run + (verified live: both 32px, same primary fill). + +Evidence: studio suite 213 passed (3 new chip/hang-off/CTA pins; existing +pins retargeted — expanded-status pins render via `defaultStatusOpen` +because static markup can't click); tsc clean; live-verified dark + light +(bar order, hang-off open/dismiss, gear↔hang-off exclusivity, CTA parity +measured via computed styles). system.md gained the Z-wave amendment +(Game bar v3 + console glyph registry v3); Pass-4 glyph registry marked +superseded.