Step 10E.3: HUD header, icon navigation and attention badges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:31:58 -04:00
parent 526ca138fc
commit 1daed1d251
11 changed files with 280 additions and 49 deletions

View File

@ -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`. - **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. - **`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/<sprite>@1x|2x.png`) through `ART_BASE_URL`, so screens and map share one art pipeline. - **`Art` uses the CR-003 generated sprites directly** (`/assets/map-gen/<sprite>@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.

View File

@ -5,6 +5,7 @@ import { startClockTicker, syncClock } from "./clock/clock";
import { AwaySummary } from "./components/AwaySummary"; import { AwaySummary } from "./components/AwaySummary";
import { DealModal } from "./components/DealModal"; import { DealModal } from "./components/DealModal";
import { formatCredits } from "./format"; import { formatCredits } from "./format";
import { tabAttention } from "./layout/attention";
import { Layout } from "./layout/Layout"; import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes"; import { SCREENS } from "./routes";
import { Contracts } from "./screens/Contracts"; import { Contracts } from "./screens/Contracts";
@ -15,6 +16,7 @@ import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots"; import { Pilots } from "./screens/Pilots";
import { StartScreen } from "./screens/StartScreen"; import { StartScreen } from "./screens/StartScreen";
import { usePlayerState, useRefetchOnArrival } from "./state/player"; import { usePlayerState, useRefetchOnArrival } from "./state/player";
import { Button } from "./ui";
// The map pulls in PixiJS; load it only when the Map tab is opened. // 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 }))); const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
@ -52,21 +54,20 @@ export function App() {
const retry = () => void state.refetch(); const retry = () => void state.refetch();
if (state.isPending) { if (state.isPending) {
return <p className="p-6 text-slate-400">Loading…</p>; return (
<p className="grid min-h-dvh place-items-center font-display tracking-[0.3em] text-ink-400 uppercase">
Loading…
</p>
);
} }
if (state.data === undefined) { if (state.data === undefined) {
// The first load failed: nothing to show yet. // The first load failed: nothing to show yet.
return ( return (
<div role="alert" className="space-y-3 p-6"> <div role="alert" className="grid min-h-dvh place-content-center gap-4 p-6 text-center">
<p className="text-red-400">Could not reach the server.</p> <p className="font-display text-lg text-rose-300">Could not reach the server.</p>
<button <Button onClick={retry} disabled={state.isFetching}>
type="button"
onClick={retry}
disabled={state.isFetching}
className="rounded-lg bg-amber-500 px-4 py-2 font-semibold text-slate-950 disabled:opacity-60"
>
{state.isFetching ? "Retrying…" : "Retry"} {state.isFetching ? "Retrying…" : "Retry"}
</button> </Button>
</div> </div>
); );
} }
@ -76,12 +77,12 @@ export function App() {
const banner = state.isError ? ( const banner = state.isError ? (
<div <div
role="alert" role="alert"
className="flex items-center justify-between gap-2 bg-red-950 px-4 py-2 text-sm text-red-200" className="flex items-center justify-between gap-2 border-b border-rose-500/40 bg-rose-950/80 px-4 py-2 text-sm text-rose-200 backdrop-blur"
> >
<span>Offline — showing last update.</span> <span>Offline — showing last update.</span>
<button type="button" onClick={retry} className="underline"> <Button variant="danger" size="sm" onClick={retry}>
Retry Retry
</button> </Button>
</div> </div>
) : null; ) : null;
@ -89,7 +90,13 @@ export function App() {
<> <>
<Routes> <Routes>
<Route <Route
element={<Layout credits={formatCredits(state.data.company.credits)} banner={banner} />} element={
<Layout
credits={formatCredits(state.data.company.credits)}
banner={banner}
attention={tabAttention(state.data)}
/>
}
> >
{SCREENS.map((screen) => ( {SCREENS.map((screen) => (
<Route <Route

View File

@ -30,8 +30,7 @@
--shadow-glow-amber: 0 0 0 1px rgb(251 191 36 / 0.45), 0 0 22px -4px rgb(251 191 36 / 0.6); --shadow-glow-amber: 0 0 0 1px rgb(251 191 36 / 0.45), 0 0 22px -4px rgb(251 191 36 / 0.6);
--shadow-glow-cyan: 0 0 0 1px rgb(34 211 238 / 0.4), 0 0 22px -4px rgb(34 211 238 / 0.55); --shadow-glow-cyan: 0 0 0 1px rgb(34 211 238 / 0.4), 0 0 22px -4px rgb(34 211 238 / 0.55);
--shadow-glow-emerald: 0 0 0 1px rgb(52 211 153 / 0.4), 0 0 22px -4px rgb(52 211 153 / 0.55); --shadow-glow-emerald: 0 0 0 1px rgb(52 211 153 / 0.4), 0 0 22px -4px rgb(52 211 153 / 0.55);
--shadow-panel: --shadow-panel: inset 0 1px 0 0 rgb(255 255 255 / 0.05), 0 12px 32px -12px rgb(0 0 0 / 0.8);
inset 0 1px 0 0 rgb(255 255 255 / 0.05), 0 12px 32px -12px rgb(0 0 0 / 0.8);
--animate-rise: rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; --animate-rise: rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both;
--animate-glow: glow 2.4s ease-in-out infinite; --animate-glow: glow 2.4s ease-in-out infinite;

View File

@ -1,7 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { NavLink, Outlet } from "react-router"; import { NavLink, Outlet } from "react-router";
import { GameClock } from "../clock/GameClock"; import { GameClock } from "../clock/GameClock";
import { SCREENS } from "../routes"; import { SCREENS, type ScreenPath } from "../routes";
import { Icon } from "../ui";
export interface LayoutProps { export interface LayoutProps {
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */ /** Shown at the top right. Credits arrive with the player state (Step 8.5). */
@ -9,44 +10,155 @@ export interface LayoutProps {
clock?: ReactNode; clock?: ReactNode;
/** Shown under the header, e.g. the offline notice. */ /** Shown under the header, e.g. the offline notice. */
banner?: ReactNode; banner?: ReactNode;
/** Attention badge counts per tab (decision 0.5.49). */
attention?: Partial<Record<ScreenPath, number>>;
} }
/** Header with credits and game clock, the current screen, and a bottom tab bar. */ /**
export function Layout({ credits = "—", clock = <GameClock />, 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 = <GameClock />,
banner,
attention = {},
}: LayoutProps) {
return ( return (
<div className="flex min-h-dvh flex-col text-ink-50"> <div className="flex min-h-dvh flex-col text-ink-50 [--header-h:3.5rem] [--nav-h:calc(4.25rem+env(safe-area-inset-bottom))] md:pl-24 md:[--nav-h:0px]">
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2"> <header className="sticky top-0 z-10 flex h-(--header-h) items-center justify-between gap-2 border-b border-space-700/80 bg-space-950/80 px-3 backdrop-blur-xl sm:px-4">
<span className="truncate text-sm font-bold tracking-wide">DEADHEAD</span> <div className="flex min-w-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-3 text-sm tabular-nums"> <LogoMark />
<span aria-label="Credits">{credits}</span> <span className="hidden font-display text-lg font-bold tracking-[0.28em] min-[420px]:inline">
<span aria-label="Game clock" className="text-slate-400"> DEADHEAD
</span>
<span className="hidden font-display text-[10px] tracking-[0.3em] text-cyan-300/70 uppercase lg:inline">
Space Freight
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-1.5 rounded-lg border border-amber-400/45 bg-amber-500/10 px-2.5 py-1 text-amber-200 shadow-glow-amber">
<Icon name="credits" className="size-4 text-amber-300" />
<span aria-label="Credits" className="font-display text-sm font-bold tabular-nums">
{credits}
</span>
</div>
<div className="flex items-center gap-1.5 rounded-lg border border-space-600 bg-space-900/80 px-2.5 py-1 text-ink-300">
<Icon name="clock" className="size-4 text-cyan-300" />
<span aria-label="Game clock" className="font-display text-xs tabular-nums sm:text-sm">
{clock} {clock}
</span> </span>
</div> </div>
</div>
</header> </header>
{banner} {banner}
<main className="flex-1 overflow-x-hidden pb-20"> <main className="flex-1 overflow-x-hidden pb-[calc(var(--nav-h)+1.5rem)]">
<Outlet /> <Outlet />
</main> </main>
<nav <nav
aria-label="Main" aria-label="Main"
className="fixed inset-x-0 bottom-0 grid grid-cols-7 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]" 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"
> >
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 -top-px h-px bg-linear-to-r from-transparent via-amber-400/50 to-transparent md:hidden"
/>
{SCREENS.map((screen) => ( {SCREENS.map((screen) => (
<NavLink <NavTab
key={screen.path} key={screen.path}
to={screen.path} path={screen.path}
end={screen.path === "/"} label={screen.label}
className={({ isActive }) => icon={screen.icon}
`truncate px-1 py-3 text-center text-xs ${isActive ? "font-semibold text-amber-400" : "text-slate-400"}` badge={attention[screen.path] ?? 0}
} />
>
{screen.label}
</NavLink>
))} ))}
</nav> </nav>
</div> </div>
); );
} }
function NavTab({
path,
label,
icon,
badge,
}: {
path: ScreenPath;
label: string;
icon: (typeof SCREENS)[number]["icon"];
badge: number;
}) {
return (
<NavLink
to={path}
end={path === "/"}
className={({ isActive }) =>
`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 }) => (
<>
<span
aria-hidden="true"
className={`absolute top-0 h-0.5 w-8 rounded-full bg-amber-400 shadow-glow-amber transition-opacity md:top-1/2 md:left-0 md:h-8 md:w-0.5 md:-translate-y-1/2 ${
isActive ? "opacity-100" : "opacity-0"
}`}
/>
<span
className={`relative grid size-10 place-items-center rounded-xl transition md:size-12 ${
isActive
? "bg-amber-500/15 shadow-glow-amber"
: "group-hover:bg-white/5 group-active:scale-95"
}`}
>
<Icon name={icon} className="size-6" />
{badge > 0 && (
<span
aria-hidden="true"
data-testid={`badge-${label}`}
className="absolute -top-1 -right-1 grid h-4.5 min-w-4.5 animate-glow place-items-center rounded-full bg-rose-500 px-1 font-display text-[10px] font-bold text-white ring-2 ring-space-950"
>
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
<span className="font-display text-[10px] font-semibold tracking-wider uppercase sm:text-[11px]">
{label}
</span>
</>
)}
</NavLink>
);
}
/** The game's mark: an orbit ring around an arrowhead. */
function LogoMark() {
return (
<svg aria-hidden="true" viewBox="0 0 32 32" className="size-8 shrink-0">
<defs>
<linearGradient id="logo-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#fde68a" />
<stop offset="1" stopColor="#f59e0b" />
</linearGradient>
</defs>
<circle
cx="16"
cy="16"
r="13"
fill="none"
stroke="url(#logo-grad)"
strokeWidth="2.2"
strokeDasharray="58 24"
strokeLinecap="round"
transform="rotate(-50 16 16)"
/>
<circle cx="27.5" cy="9" r="1.8" fill="#67e8f9" />
<path d="M16 7.5 22.5 22 16 18.5 9.5 22z" fill="url(#logo-grad)" />
</svg>
);
}

View File

@ -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<Record<ScreenPath, number>> {
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,
};
}

View File

@ -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 = [ export const SCREENS = [
{ path: "/", title: "HQ", label: "HQ" }, { path: "/", title: "HQ", label: "HQ", icon: "hq" },
{ path: "/map", title: "Map", label: "Map" }, { path: "/map", title: "Map", label: "Map", icon: "map" },
{ path: "/contracts", title: "Contracts", label: "Jobs" }, { path: "/contracts", title: "Contracts", label: "Jobs", icon: "jobs" },
{ path: "/fleet", title: "Fleet", label: "Fleet" }, { path: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" },
{ path: "/pilots", title: "Pilots", label: "Pilots" }, { path: "/pilots", title: "Pilots", label: "Pilots", icon: "pilots" },
{ path: "/market", title: "Market", label: "Shop" }, { path: "/market", title: "Market", label: "Shop", icon: "market" },
{ path: "/locations", title: "Locations", label: "Places" }, { path: "/locations", title: "Locations", label: "Places", icon: "places" },
] as const; ] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[];
export type ScreenPath = (typeof SCREENS)[number]["path"];

View File

@ -26,9 +26,10 @@ export function MapScreen() {
const [selection, setSelection] = useState<MapSelection | null>(null); const [selection, setSelection] = useState<MapSelection | null>(null);
return ( return (
// Fill the space between the header (2.3rem) and the bottom nav (2.6rem + safe // Fill the space between the header and the bottom nav (none beside the
// area); -mb-20 cancels the layout's scroll padding so the page never scrolls. // desktop rail); the negative margin cancels the layout's scroll padding so
<section className="relative -mb-20 h-[calc(100dvh-4.875rem-env(safe-area-inset-bottom))]"> // the page never scrolls (CR-005).
<section className="relative -mb-[calc(var(--nav-h)+1.5rem)] h-[calc(100dvh-var(--header-h)-var(--nav-h))]">
<h1 className="sr-only">Map</h1> <h1 className="sr-only">Map</h1>
<MapCanvas state={state} onSelect={setSelection} selected={selection} /> <MapCanvas state={state} onSelect={setSelection} selected={selection} />
<button <button

View File

@ -0,0 +1,10 @@
import type { ReactNode } from "react";
/** A scrolling screen's content column: centred, padded, and rising in on open. */
export function Screen({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<section className={`mx-auto w-full max-w-3xl animate-rise space-y-4 p-4 md:p-6 ${className}`}>
{children}
</section>
);
}

View File

@ -1,6 +1,7 @@
/** The client's UI kit (CR-005, decision 0.5.48). */ /** The client's UI kit (CR-005, decision 0.5.48). */
export { Button, buttonClass, type ButtonProps } from "./Button"; export { Button, buttonClass, type ButtonProps } from "./Button";
export { Eyebrow, Panel, type Tone } from "./Panel"; export { Eyebrow, Panel, type Tone } from "./Panel";
export { Screen } from "./Screen";
export { Sheet } from "./Sheet"; export { Sheet } from "./Sheet";
export { Art, Meter, Pill, ScreenHeader, spriteUrl, StatTile } from "./bits"; export { Art, Meter, Pill, ScreenHeader, spriteUrl, StatTile } from "./bits";
export { Icon, type IconName } from "./icons"; export { Icon, type IconName } from "./icons";

View File

@ -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>): 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<PlayerState>) =>
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<PlayerState>);
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();
});
});

View File

@ -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.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.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.