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

View File

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

View File

@ -70,7 +70,7 @@ export function Layout({
<nav <nav
aria-label="Main" 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 <span
aria-hidden="true" aria-hidden="true"
@ -137,7 +137,7 @@ function NavTab({
</span> </span>
)} )}
</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} {label}
</span> </span>
</> </>

View File

@ -1,12 +1,14 @@
import type { IconName } from "./ui/icons"; 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 = [ 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: "/map", title: "Map", label: "Map", icon: "map" },
{ path: "/contracts", title: "Contracts", label: "Jobs", icon: "jobs" }, { path: "/contracts", title: "Contracts", label: "Jobs", icon: "jobs" },
{ path: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" }, { path: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" },
{ path: "/pilots", title: "Pilots", label: "Pilots", icon: "pilots" }, { 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: "/market", title: "Market", label: "Shop", icon: "market" },
{ path: "/locations", title: "Locations", label: "Places", icon: "places" }, { path: "/locations", title: "Locations", label: "Places", icon: "places" },
] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[]; ] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[];

View File

@ -47,7 +47,7 @@ export function HQ() {
return ( return (
<Screen className="max-w-5xl"> <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"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile <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" />, 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). // A cog (outline after Lucide "settings", ISC licence).
gear: ( gear: (
<> <>

View File

@ -72,9 +72,9 @@ describe("something waiting on return", () => {
const ready = within(dialog).getByRole("list", { name: "Ready goals" }); const ready = within(dialog).getByRole("list", { name: "Ready goals" });
expect(ready).toHaveTextContent(content.onboardingGoal("first-own-delivery")!.title); 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(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" }); const goals = screen.getByRole("region", { name: "Goals" });
expect(within(goals).getByRole("button", { name: "Claim" })).toBeInTheDocument(); expect(within(goals).getByRole("button", { name: "Claim" })).toBeInTheDocument();
await waitFor(() => 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 () => { it("does not show the dialog when there is nothing to report", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() })); 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(); 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 userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {})); 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( expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }), screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();

View File

@ -66,7 +66,7 @@ describe("goals on HQ", () => {
it("is not shown during the tutorial", async () => { it("is not shown during the tutorial", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() })); 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(); 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)], activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
}); });
renderApp(fakeApi({ getPlayerState: () => state })); 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; const statValue = (label: string) => screen.getByText(label).nextElementSibling;
expect(statValue("Ships idle")).toHaveTextContent("1"); expect(statValue("Ships idle")).toHaveTextContent("1");
@ -132,7 +132,7 @@ describe("loan on HQ", () => {
it("is hidden once the loan is paid off", async () => { it("is hidden once the loan is paid off", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) })); 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(); expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
}); });

View File

@ -38,4 +38,15 @@ describe("app layout", () => {
"page", "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 () => { it("skips the start screen when a stored session is still valid", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() }); const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api); 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(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything()); 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)] }), getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }),
}); });
renderApp(api); renderApp(api);
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
expect(stateCalls(api)).toBe(1); expect(stateCalls(api)).toBe(1);
act(() => vi.advanceTimersByTime(29_000)); act(() => vi.advanceTimersByTime(29_000));
@ -67,7 +67,7 @@ describe("player state store on focus", () => {
it("refetches when the app regains focus", async () => { it("refetches when the app regains focus", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() }); const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api); renderApp(api);
await screen.findByRole("heading", { level: 1, name: "HQ" }); await screen.findByRole("heading", { level: 1, name: "Dashboard" });
const before = stateCalls(api); const before = stateCalls(api);
// Leaving and returning to the app, as the browser reports it to TanStack Query. // 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(); expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Shake on it" })); 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. // The deal is done, but the permission prompt is still open.
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(); 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(() => await waitFor(() =>
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(), 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(); expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
act(() => usePreferences.getState().showTipsAgain()); 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 () => { it("doesn't appear with no unspent points", async () => {
renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 0) }), "/"); 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. // Give the prompt check a turn to settle.
await act(async () => {}); await act(async () => {});
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(); 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) ## 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. 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).