Step 20.1: Bridge and HQs tabs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b6bf64a82
commit
a16c34ffc3
|
|
@ -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 />,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ export function AwaySummary() {
|
||||||
}}
|
}}
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
>
|
>
|
||||||
Claim on HQ
|
Claim on the Bridge
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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 }[];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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: (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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(() =>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue