Step 20.1: Bridge and HQs tabs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 22:22:09 -04:00
parent 4b6bf64a82
commit a16c34ffc3
17 changed files with 53 additions and 19 deletions

View File

@ -12,6 +12,7 @@ import { SCREENS } from "./routes";
import { Contracts } from "./screens/Contracts";
import { Fleet } from "./screens/Fleet";
import { HQ } from "./screens/HQ";
import { HQs } from "./screens/HQs";
import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots";
@ -34,6 +35,7 @@ const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/contracts": <Contracts />,
"/fleet": <Fleet />,
"/pilots": <Pilots />,
"/hqs": <HQs />,
"/market": <Market />,
"/locations": <Locations />,
};

View File

@ -154,7 +154,7 @@ export function AwaySummary() {
}}
className="mt-3"
>
Claim on HQ
Claim on the Bridge
</Button>
</div>
)}

View File

@ -70,7 +70,7 @@ export function Layout({
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-7 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-1 md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4"
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-8 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-1 md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4"
>
<span
aria-hidden="true"
@ -137,7 +137,7 @@ function NavTab({
</span>
)}
</span>
<span className="font-display text-[10px] font-semibold tracking-wider uppercase sm:text-[11px]">
<span className="font-display text-[10px] font-semibold tracking-wide uppercase sm:text-[11px] sm:tracking-wider">
{label}
</span>
</>

View File

@ -1,12 +1,14 @@
import type { IconName } from "./ui/icons";
/** The app's top-level screens, in navigation order. Labels are short to fit seven tabs. */
/** The app's top-level screens, in navigation order. Labels are short to fit eight tabs. */
export const SCREENS = [
{ path: "/", title: "HQ", label: "HQ", icon: "hq" },
// The Dashboard, "Bridge" in the navigation (CR-020, decision 0.5.118).
{ path: "/", title: "Dashboard", label: "Bridge", icon: "helm" },
{ path: "/map", title: "Map", label: "Map", icon: "map" },
{ path: "/contracts", title: "Contracts", label: "Jobs", icon: "jobs" },
{ path: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" },
{ path: "/pilots", title: "Pilots", label: "Pilots", icon: "pilots" },
{ path: "/hqs", title: "HQs", label: "HQs", icon: "hq" },
{ path: "/market", title: "Market", label: "Shop", icon: "market" },
{ path: "/locations", title: "Locations", label: "Places", icon: "places" },
] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[];

View File

@ -47,7 +47,7 @@ export function HQ() {
return (
<Screen className="max-w-5xl">
<ScreenHeader title="HQ" eyebrow={state.company.name} icon="hq" />
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile

View File

@ -0,0 +1,10 @@
import { Screen, ScreenHeader } from "../ui";
/** The player's HQs (bays): information only (CR-020, decision 0.5.121). */
export function HQs() {
return (
<Screen className="max-w-4xl">
<ScreenHeader title="HQs" eyebrow="Your bases" icon="hq" />
</Screen>
);
}

View File

@ -82,6 +82,14 @@ const PATHS = {
</>
),
close: <path d="M6 6l12 12M18 6 6 18" />,
// A ship's helm: the Bridge (CR-020).
helm: (
<>
<circle cx="12" cy="12" r="6.5" />
<circle cx="12" cy="12" r="2" />
<path d="M12 2v3.5M12 18.5V22M2 12h3.5M18.5 12H22M4.9 4.9l2.5 2.5M16.6 16.6l2.5 2.5M4.9 19.1l2.5-2.5M16.6 7.4l2.5-2.5" />
</>
),
// A cog (outline after Lucide "settings", ISC licence).
gear: (
<>

View File

@ -72,9 +72,9 @@ describe("something waiting on return", () => {
const ready = within(dialog).getByRole("list", { name: "Ready goals" });
expect(ready).toHaveTextContent(content.onboardingGoal("first-own-delivery")!.title);
await userEvent.click(within(dialog).getByRole("button", { name: "Claim on HQ" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Claim on the Bridge" }));
expect(screen.queryByRole("dialog", { name: "While you were away" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
const goals = screen.getByRole("region", { name: "Goals" });
expect(within(goals).getByRole("button", { name: "Claim" })).toBeInTheDocument();
await waitFor(() =>

View File

@ -155,7 +155,7 @@ describe("while you were away", () => {
it("does not show the dialog when there is nothing to report", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

View File

@ -144,7 +144,7 @@ describe("the deal", () => {
);
await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument();

View File

@ -66,7 +66,7 @@ describe("goals on HQ", () => {
it("is not shown during the tutorial", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByRole("region", { name: "Goals" })).not.toBeInTheDocument();
});
});

View File

@ -54,7 +54,7 @@ describe("HQ screen", () => {
activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
});
renderApp(fakeApi({ getPlayerState: () => state }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
expect(statValue("Ships idle")).toHaveTextContent("1");
@ -132,7 +132,7 @@ describe("loan on HQ", () => {
it("is hidden once the loan is paid off", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
});

View File

@ -38,4 +38,15 @@ describe("app layout", () => {
"page",
);
});
it("opens on the Bridge tab, the Dashboard, with HQs among eight tabs (CR-020)", async () => {
renderApp(signedIn());
const nav = await screen.findByRole("navigation", { name: "Main" });
const links = within(nav).getAllByRole("link");
expect(links).toHaveLength(8);
expect(links[0]).toHaveAccessibleName("Bridge");
expect(links[0]).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect(within(nav).getByRole("link", { name: "HQs" })).toHaveAttribute("href", "/hqs");
});
});

View File

@ -44,7 +44,7 @@ describe("guest onboarding", () => {
it("skips the start screen when a stored session is still valid", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api);
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything());
});

View File

@ -46,7 +46,7 @@ describe("player state store", () => {
getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }),
});
renderApp(api);
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(stateCalls(api)).toBe(1);
act(() => vi.advanceTimersByTime(29_000));
@ -67,7 +67,7 @@ describe("player state store on focus", () => {
it("refetches when the app regains focus", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api);
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
const before = stateCalls(api);
// Leaving and returning to the app, as the browser reports it to TanStack Query.

View File

@ -70,7 +70,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Shake on it" }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
// The deal is done, but the permission prompt is still open.
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
@ -100,7 +100,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(),
);
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
act(() => usePreferences.getState().showTipsAgain());
@ -109,7 +109,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
it("doesn't appear with no unspent points", async () => {
renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 0) }), "/");
await screen.findByRole("heading", { level: 1, name: "HQ" });
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
// Give the prompt check a turn to settle.
await act(async () => {});
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();

View File

@ -451,3 +451,4 @@ Spec: `memory-bank/change-requests/CR-019-completable-boards.md` (received as "C
## Phase 20 — Bridge Dashboard and HQs Tab (CR-020)
Spec: `memory-bank/change-requests/CR-020-bridge-dashboard.md`. New decisions recorded as plan §0.5.118–0.5.121.
- [x] **Step 20.1 — Navigation: Bridge and HQs.** `SCREENS`: `/` is now "Dashboard" with the tab label "Bridge" and a new `helm` icon; `/hqs` ("HQs", HQ icon) sits after Pilots, with a first `HQs` screen (header only until 20.2). The bottom bar is eight columns; labels tighten their tracking below `sm` so "PILOTS"/"PLACES" fit about 48 px. The goals badge stays on `/`. The HQ screen's heading is "Dashboard"; the away summary's "Claim on HQ" is "Claim on the Bridge" (tests that looked for the "HQ" heading updated). Test: `layout.test.tsx` (eight links, Bridge first and current on launch, HQs at `/hqs`; every screen's heading at its path).