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:
parent
526ca138fc
commit
1daed1d251
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{clock}
|
|
||||||
</span>
|
</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}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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"];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue