diff --git a/DECISIONS.md b/DECISIONS.md index 306a143..9f5f7e7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -840,3 +840,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Icons are hand-drawn inline SVG paths** (`ui/icons.tsx`), not an icon package: 28 icons, no network, `currentColor`, always `aria-hidden`. - **`Sheet` renders the title itself** so every dialog gets the same eyebrow/title/subtitle layout and the `aria-labelledby` wiring cannot be forgotten. - **`Art` uses the CR-003 generated sprites directly** (`/assets/map-gen/@1x|2x.png`) through `ART_BASE_URL`, so screens and map share one art pipeline. + +## Step 10E.3 + +- **Jobs badge during the tutorial is 1 until a run is in flight**: the loaner isn't a ship in state, but "go fly a run" is the one thing the player should do next. +- **The desktop rail is 96 px with labels under icons**, not a wide sidebar, so the map and screens keep the room. The wordmark hides below 420 px wide so the credits and clock chips fit on small phones. +- **Screenshot stack:** a scratchpad Playwright harness (playwright-core driving local Chrome) seeds a tutorial save and a mid-game save through the API on a separate database (`deadhead_shots`, API 3125, web 5199), as in Step 10C. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 231f497..fbdce8a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { startClockTicker, syncClock } from "./clock/clock"; import { AwaySummary } from "./components/AwaySummary"; import { DealModal } from "./components/DealModal"; import { formatCredits } from "./format"; +import { tabAttention } from "./layout/attention"; import { Layout } from "./layout/Layout"; import { SCREENS } from "./routes"; import { Contracts } from "./screens/Contracts"; @@ -15,6 +16,7 @@ import { Market } from "./screens/Market"; import { Pilots } from "./screens/Pilots"; import { StartScreen } from "./screens/StartScreen"; import { usePlayerState, useRefetchOnArrival } from "./state/player"; +import { Button } from "./ui"; // The map pulls in PixiJS; load it only when the Map tab is opened. const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen }))); @@ -52,21 +54,20 @@ export function App() { const retry = () => void state.refetch(); if (state.isPending) { - return

Loading…

; + return ( +

+ Loading… +

+ ); } if (state.data === undefined) { // The first load failed: nothing to show yet. return ( -
-

Could not reach the server.

- +
); } @@ -76,12 +77,12 @@ export function App() { const banner = state.isError ? (
Offline — showing last update. - +
) : null; @@ -89,7 +90,13 @@ export function App() { <> } + element={ + + } > {SCREENS.map((screen) => ( >; } -/** Header with credits and game clock, the current screen, and a bottom tab bar. */ -export function Layout({ credits = "—", clock = , banner }: LayoutProps) { +/** + * HUD header with credits and game clock, the current screen, and icon + * navigation: a bottom bar on phones, a left rail from `md` (CR-005). + * `--header-h` and `--nav-h` let full-height screens (the map) fit exactly. + */ +export function Layout({ + credits = "—", + clock = , + banner, + attention = {}, +}: LayoutProps) { return ( -
-
- DEADHEAD -
- {credits} - - {clock} +
+
+
+ + + DEADHEAD + + Space Freight + +
+
+
+ + + {credits} + +
+
+ + + {clock} + +
{banner} -
+
); } + +function NavTab({ + path, + label, + icon, + badge, +}: { + path: ScreenPath; + label: string; + icon: (typeof SCREENS)[number]["icon"]; + badge: number; +}) { + return ( + + `group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-2 ${ + isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50" + }` + } + > + {({ isActive }) => ( + <> + + ); +} + +/** The game's mark: an orbit ring around an arrowhead. */ +function LogoMark() { + return ( + + ); +} diff --git a/apps/web/src/layout/attention.ts b/apps/web/src/layout/attention.ts new file mode 100644 index 0000000..b76b6a2 --- /dev/null +++ b/apps/web/src/layout/attention.ts @@ -0,0 +1,22 @@ +import type { PlayerState } from "@deadhead/schema"; +import type { ScreenPath } from "../routes"; + +/** + * Counts for the navigation's attention badges (decision 0.5.49): goals ready + * to claim, ships ready for a job, and pilots with skill points to spend. + */ +export function tabAttention(state: PlayerState): Partial> { + const restingIds = new Set(state.pilots.filter((p) => p.resting).map((p) => p.id)); + const readyShips = state.tutorial + ? state.tutorial.runInFlight + ? 0 + : 1 + : state.ships.filter( + (s) => s.status === "idle" && s.pilotId !== null && !restingIds.has(s.pilotId), + ).length; + return { + "/": state.goals.filter((g) => g.status === "ready").length, + "/contracts": readyShips, + "/pilots": state.pilots.filter((p) => p.skillPointsAvailable > 0).length, + }; +} diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index 3183960..7326b90 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -1,10 +1,14 @@ -/** The app's top-level screens, in bottom-navigation order. Labels are short to fit seven tabs. */ +import type { IconName } from "./ui/icons"; + +/** The app's top-level screens, in navigation order. Labels are short to fit seven tabs. */ export const SCREENS = [ - { path: "/", title: "HQ", label: "HQ" }, - { path: "/map", title: "Map", label: "Map" }, - { path: "/contracts", title: "Contracts", label: "Jobs" }, - { path: "/fleet", title: "Fleet", label: "Fleet" }, - { path: "/pilots", title: "Pilots", label: "Pilots" }, - { path: "/market", title: "Market", label: "Shop" }, - { path: "/locations", title: "Locations", label: "Places" }, -] as const; + { path: "/", title: "HQ", label: "HQ", icon: "hq" }, + { 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: "/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 }[]; + +export type ScreenPath = (typeof SCREENS)[number]["path"]; diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index ebc68ed..6c2225e 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -26,9 +26,10 @@ export function MapScreen() { const [selection, setSelection] = useState(null); return ( - // Fill the space between the header (2.3rem) and the bottom nav (2.6rem + safe - // area); -mb-20 cancels the layout's scroll padding so the page never scrolls. -
+ // Fill the space between the header and the bottom nav (none beside the + // desktop rail); the negative margin cancels the layout's scroll padding so + // the page never scrolls (CR-005). +

Map

+ ); +} diff --git a/apps/web/src/ui/index.ts b/apps/web/src/ui/index.ts index 2e01ab6..421739f 100644 --- a/apps/web/src/ui/index.ts +++ b/apps/web/src/ui/index.ts @@ -1,6 +1,7 @@ /** The client's UI kit (CR-005, decision 0.5.48). */ export { Button, buttonClass, type ButtonProps } from "./Button"; export { Eyebrow, Panel, type Tone } from "./Panel"; +export { Screen } from "./Screen"; export { Sheet } from "./Sheet"; export { Art, Meter, Pill, ScreenHeader, spriteUrl, StatTile } from "./bits"; export { Icon, type IconName } from "./icons"; diff --git a/apps/web/test/attention.test.tsx b/apps/web/test/attention.test.tsx new file mode 100644 index 0000000..d8d935e --- /dev/null +++ b/apps/web/test/attention.test.tsx @@ -0,0 +1,68 @@ +import type { PlayerState, Ship } from "@deadhead/schema"; +import { screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { tabAttention } from "../src/layout/attention"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const ship = (overrides: Partial): Ship => ({ + id: "s", + modelId: "courier", + name: "Ship", + status: "idle", + locationId: "earth", + dispatchId: null, + lastArrivalAt: 0, + pilotId: null, + ...overrides, +}); + +const base = samplePlayerState(); +const avatar = base.pilots[0]!; + +const owner = (overrides: Partial) => + samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }], ...overrides }); + +describe("navigation attention badges (Step 10E.3)", () => { + it("counts ready goals, ships ready for a job and pilots with skill points", () => { + const state = owner({ + goals: [ + { id: "a", status: "ready" }, + { id: "b", status: "incomplete" }, + { id: "c", status: "ready" }, + ], + ships: [ + ship({ id: "s1", pilotId: "avatar" }), // ready + ship({ id: "s2", pilotId: "p2" }), // pilot resting + ship({ id: "s3", pilotId: null }), // uncrewed + ship({ id: "s4", pilotId: "p3", status: "in-flight", locationId: null }), + ], + pilots: [ + { ...avatar, assignedShipId: "s1", skillPointsAvailable: 2 }, + { + ...avatar, + id: "p2", + isAvatar: false, + assignedShipId: "s2", + resting: true, + restEndsAt: 1, + }, + { ...avatar, id: "p3", isAvatar: false, assignedShipId: "s4", skillPointsAvailable: 1 }, + ], + } as Partial); + expect(tabAttention(state)).toEqual({ "/": 2, "/contracts": 1, "/pilots": 2 }); + }); + + it("nudges toward Jobs during the tutorial until a run is in flight", () => { + expect(tabAttention(base)["/contracts"]).toBe(1); + const flying = samplePlayerState({ tutorial: { ...base.tutorial!, runInFlight: true } }); + expect(tabAttention(flying)["/contracts"]).toBe(0); + }); + + it("shows a badge without changing the tab's accessible name", async () => { + renderApp(fakeApi({ getPlayerState: () => base })); + const nav = await screen.findByRole("navigation", { name: "Main" }); + const jobs = within(nav).getByRole("link", { name: "Jobs" }); + expect(within(jobs).getByTestId("badge-Jobs")).toHaveTextContent("1"); + expect(within(nav).queryByTestId("badge-Pilots")).not.toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 1480904..ac471e1 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -181,3 +181,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde - [x] **Step 10E.1 — Fonts, theme tokens and backdrop.** `@fontsource/chakra-petch` (display) and `@fontsource/barlow` (body), self-hosted. `index.css` `@theme`: `font-display`/`font-sans`, `space-*` surfaces, `ink-*` text, glow and panel shadows, `rise`/`glow`/`sheet` animations. `body::before` puts the map's `bg.webp` under a navy gradient behind every screen; global `:focus-visible` ring; all motion off under `prefers-reduced-motion`. Test: `theme.test.ts`. - [x] **Step 10E.2 — UI kit.** `apps/web/src/ui/` (barrel `index.ts`): `Button`/`buttonClass` (primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback), `Panel` (tones, `feature` corner brackets, polymorphic `as`) and `Eyebrow`, `Sheet` (modal: bottom sheet on phones, centred card from `sm`, renders its own `h2`), `Pill`, `Meter` (labelled `progressbar`), `StatTile`, `ScreenHeader` (the screen's `h1`), `Art`/`spriteUrl` (generated map sprites, 1x/2x `srcSet`, hides on error) and `Icon` (28 line icons). Test: `ui-kit.test.tsx`. +- [x] **Step 10E.3 — App shell.** `Layout`: HUD header (logo mark, wordmark from 420 px, glowing credits chip, clock chip; `Credits` / `Game clock` labels kept) and icon navigation, a bottom bar on phones and a 96 px left rail from `md` (decision 0.5.49). `SCREENS` gained an `icon`. Attention badges from the pure `tabAttention(state)` (`layout/attention.ts`): HQ = goals ready, Jobs = idle crewed ships whose pilot isn't resting (1 during the tutorial until a run is in flight), Pilots = pilots with unspent skill points; badges are `aria-hidden`. `--header-h`/`--nav-h` CSS variables let the map fill exactly the space left on both layouts. `ui/Screen` is the content column (max 48 rem, rise-in). App loading, error and offline states use the kit. Test: `attention.test.tsx` (counts; badge doesn't change the tab's name); `layout.test.tsx` unchanged and passing.