Compare commits

...

10 Commits

Author SHA1 Message Date
fireshaper 81e71e8436 Config: 10-minute board period and 10-hour rest
CI / check (push) Successful in 4m25s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:13:49 -04:00
fireshaper f4ddf1dc04 Step 10E.9: CR-005 plan amendments, regression and review screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:55:43 -04:00
fireshaper c11d52f96f Step 10E.8: locations, map panels, title screen and dialogs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:51:00 -04:00
fireshaper 45a21a8684 Step 10E.7: pilot cards with skills, XP and duty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:47:22 -04:00
fireshaper 6d5ea7cb48 Step 10E.6: fleet and market with ship art
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:45:23 -04:00
fireshaper 939ee45943 Step 10E.5: contracts, tutorial board and dispatch sheet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:42:25 -04:00
fireshaper a69ffcc434 Step 10E.4: HQ dashboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:37:28 -04:00
fireshaper 1daed1d251 Step 10E.3: HUD header, icon navigation and attention badges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:32:38 -04:00
fireshaper 526ca138fc Step 10E.2: UI kit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:27:36 -04:00
fireshaper 45eafb2503 Step 10E.1: fonts, theme tokens and space backdrop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:24:29 -04:00
65 changed files with 2377 additions and 751 deletions

View File

@ -823,3 +823,54 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The CR's manual check was scripted** against a live dev server with the test clock (scratchpad `cr004-smoke.mjs`): mandatory rest after the sixth Earth↔Moon trip, `pilot-resting` while resting, and a different board on waking. The user's own check on a device is still the review gate.
- **Observed for Phase 12 balance (not changed here):** a board's only guaranteed Courier job is used by the first trip from that location in a period, so later trips in the same period are usually deadheads. Resting, which wakes the pilot to a new board, is now the natural answer, which is what the CR intends; the size of that effect is for the balance pass.
## CR-005 — UI Overhaul (Phase 10E)
- **Change request CR-005 is stored in `memory-bank/change-requests/`** and inserted as Phase 10E, after Phase 10D and before Phase 11. Its decisions are plan §0.5.47–0.5.51.
- **Requested by the user** ("the UI is very simple, has small text buttons on the bottom of the screen, and isn't very eye catching as a game"). Direction chosen: a "freight terminal HUD" that reuses the CR-003 art, so no new art is needed.
## Step 10E.1
- **Fonts via `@fontsource`, latin subset only, weights 500–700 (Chakra Petch) and 400–600 (Barlow).** Self-hosting keeps the game working offline for the Phase 11 PWA; Google Fonts would not.
- **The backdrop is `body::before` (fixed, `z-index: -1`)**, so it never scrolls and screens no longer paint their own background. The layout's `bg-slate-950` was removed so it shows through.
## Step 10E.2
- **No component library.** The kit is a handful of small Tailwind components in `src/ui/`; a dependency like Radix or shadcn would be more than the game needs.
- **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/<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.
## Step 10E.4
- **"Fly a company run" on the tutorial HQ panel is the one copy addition so far**: a link to Jobs, shown only when no run is in flight and no deal is waiting. It adds a path; nothing existing changed (decision 0.5.51 is about not changing existing copy and names).
- **`ScreenHeader` is a `div`, not a `header`**: a second `<header>` was exposed as another `banner` landmark next to the app header.
- **Route strip labels are CSS-generated** (`after:content-[attr(data-label)]`) because tests and screen readers already get the route as text; DOM text in a decorative strip would duplicate it.
## Step 10E.5
- **Sheets portal to `document.body`.** The screen column animates in with a `transform`, which makes it the containing block for `position: fixed`; the first dispatch-sheet screenshot showed the backdrop but the sheet stranded inside the column, under the header and nav. Portalling fixes all sheets at once and keeps tests working (they query `screen`).
- **Contract cards keep their texts in the same order and grouping** ("Standard (tier 2) · 50 t · …", "Needs …", "expires in …") so the board tests stay unchanged, and they contain no nested list items (tests count `listitem`s).
- **Dispatch outlook cells wrap each `dt`/`dd` pair in a `div`** (valid inside `dl`), so text still reads "Trip2h 05m".
## Step 10E.6
- **Market spec bars are decorative (`aria-hidden`)** and relative to the best purchasable model; the numbers stay in `dt`/`dd` text. Range uses `log10(1 + range)` because a linear bar made the Courier's 5M km invisible next to the Bulk Hauler's 1,000M km.
- **Idle/In flight pills are new visible text** on Fleet cards; they restate the ship's status, which was implied before by "At …" or a route.
## Step 10E.9
- **Final screenshots are palette-quantized** (sharp `palette: true`, quality 90): 24 shots in 6 MB instead of 18 MB, with no visible banding in the art.
- **The 500 KB chunk warning is accepted for now.** The UI kit and screens added about 27 KB to the main chunk; route-level code splitting (like the map's) can take it back under when the PWA work in Phase 11 looks at load size.
## Config change: shorter board period and rest
- **`contractBoardPeriodRealMinutes` 60 → 10 and `restGameHours` 60 → 10** (user request, after Phase 10D). At 60× compression a rest is now 10 real minutes, exactly one board period, so decision 0.5.46 still holds and its content test (rest in real time ≥ board period) is unchanged and passing.
- **Effects:** boards refresh every 10 real minutes and every contract expires at the end of its period, so offers turn over six times as fast. A rested pilot is back in 10 minutes instead of an hour. This also resolves the earlier note that the CR-004 example expected a short rest.
- **Two tests assumed a 60-minute period** and now derive their timing from config instead: the server's `testContract` helper (expiry = end of the board period) and the board-refresh countdown test. No game code changed.

View File

@ -3,6 +3,7 @@ import { content } from "@deadhead/content";
import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema";
import {
boardPeriodIndex,
boardPeriodMs,
createDispatchRecord,
createRepositionRecord,
type Contract,
@ -105,7 +106,8 @@ export function testContract(overrides: Partial<Contract> = {}): Contract {
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: TEST_NOW,
expiresAt: TEST_NOW + 3_600_000,
// Contracts expire at the end of their board period (TEST_NOW is a period start).
expiresAt: TEST_NOW + boardPeriodMs(content.config),
firstContact: false,
...overrides,
};

View File

@ -14,6 +14,8 @@
"@deadhead/content": "workspace:*",
"@deadhead/schema": "workspace:*",
"@deadhead/sim": "workspace:*",
"@fontsource/barlow": "^5.3.0",
"@fontsource/chakra-petch": "^5.3.0",
"@tanstack/react-query": "^5.104.1",
"pixi.js": "^8.22.0",
"react": "^19.3.0",

View File

@ -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 })));
@ -22,7 +24,7 @@ const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ defaul
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/": <HQ />,
"/map": (
<Suspense fallback={<p className="p-4 text-slate-400">Loading map…</p>}>
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
<MapScreen />
</Suspense>
),
@ -52,21 +54,20 @@ export function App() {
const retry = () => void state.refetch();
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) {
// The first load failed: nothing to show yet.
return (
<div role="alert" className="space-y-3 p-6">
<p className="text-red-400">Could not reach the server.</p>
<button
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"
>
<div role="alert" className="grid min-h-dvh place-content-center gap-4 p-6 text-center">
<p className="font-display text-lg text-rose-300">Could not reach the server.</p>
<Button onClick={retry} disabled={state.isFetching}>
{state.isFetching ? "Retrying…" : "Retry"}
</button>
</Button>
</div>
);
}
@ -76,12 +77,12 @@ export function App() {
const banner = state.isError ? (
<div
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>
<button type="button" onClick={retry} className="underline">
<Button variant="danger" size="sm" onClick={retry}>
Retry
</button>
</Button>
</div>
) : null;
@ -89,7 +90,13 @@ export function App() {
<>
<Routes>
<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) => (
<Route

View File

@ -4,6 +4,7 @@ import { useNavigate } from "react-router";
import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
import { useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Sheet } from "../ui";
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */
export function AwaySummary() {
@ -31,37 +32,35 @@ export function AwaySummary() {
};
return (
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 p-4 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="away-title"
className="max-h-[80dvh] w-full max-w-md overflow-y-auto rounded-xl border border-slate-700 bg-slate-900 p-4"
>
<h2 id="away-title" className="text-lg font-semibold">
While you were away
</h2>
<ul className="mt-3 space-y-3">
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away">
<ul className="mt-4 space-y-3">
{summaries.map((s) => (
<li key={s.dispatchId} className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="font-medium">{shipName(s.shipId, s.tutorial)}</div>
<div className="text-slate-400">
<li
key={s.dispatchId}
className="rounded-xl border border-space-600 bg-space-950/50 p-3 text-sm"
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">
{shipName(s.shipId, s.tutorial)}
</span>
<span className="text-ink-400">
{locationName(s.originId)} → {locationName(s.destinationId)}
</span>
</div>
<div className="mt-1 flex gap-4 tabular-nums">
<span className={s.credits >= 0 ? "text-emerald-400" : "text-red-400"}>
<div className="mt-2 flex gap-4 font-display text-lg font-bold tabular-nums">
<span className={s.credits >= 0 ? "text-emerald-300" : "text-rose-300"}>
{s.credits >= 0 ? "+" : ""}
{formatCredits(s.credits)}
</span>
<span>+{s.pilotXp} XP</span>
<span className="text-violet-300">+{s.pilotXp} XP</span>
</div>
{s.loanRepayment > 0 && (
<div className="mt-1 text-slate-400">
<div className="mt-1 text-ink-400">
{formatCredits(s.loanRepayment)} went to the lender
</div>
)}
{s.scriptedId && (
<p className="mt-1 italic text-fuchsia-200">
<p className="mt-1 text-fuchsia-200 italic">
{
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
?.flavorMessage
@ -69,49 +68,55 @@ export function AwaySummary() {
</p>
)}
{s.discoveredLocationId && (
<div className="mt-1 text-amber-300">
<div className="mt-2 flex items-center gap-1.5 font-display font-semibold text-amber-300">
<Icon name="star" className="size-4" />
Discovered {locationName(s.discoveredLocationId)}!
</div>
)}
{s.restEndsAt !== null && (
<div className="mt-1 text-sky-300">
<div className="mt-1 flex items-center gap-1.5 text-sky-300">
<Icon name="rest" className="size-4 shrink-0" />
<span>
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting
until {formatLocalTime(s.restEndsAt)}
</span>
</div>
)}
</li>
))}
</ul>
{readyGoals.length > 0 && (
<div className="mt-3 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<h3 className="font-semibold text-emerald-300">Goals ready to claim</h3>
<div className="mt-3 rounded-xl border border-emerald-400/40 bg-emerald-950/40 p-3 text-sm shadow-glow-emerald">
<h3 className="flex items-center gap-1.5 font-display font-bold text-emerald-300">
<Icon name="target" className="size-4" />
Goals ready to claim
</h3>
<ul aria-label="Ready goals" className="mt-1 space-y-0.5">
{readyGoals.map((goal) => (
<li key={goal.id}>
{goal.title} <span className="text-slate-400">+{formatCredits(goal.reward)}</span>
{goal.title}{" "}
<span className="font-display text-amber-300/90">
+{formatCredits(goal.reward)}
</span>
</li>
))}
</ul>
<button
type="button"
<Button
variant="success"
size="sm"
onClick={() => {
dismiss();
void navigate({ pathname: "/", hash: "#goals" });
}}
className="mt-2 font-medium text-emerald-300 underline"
className="mt-3"
>
Claim on HQ
</button>
</Button>
</div>
)}
<button
type="button"
onClick={dismiss}
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950"
>
<Button block onClick={dismiss} className="mt-4">
Got it
</button>
</div>
</div>
</Button>
</Sheet>
);
}

View File

@ -5,6 +5,7 @@ import { useApi } from "../api/context";
import { formatCredits } from "../format";
import { errorMessage, useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { Art, Button, Sheet } from "../ui";
/**
* The one-time deal (decision 0.5.30): shown when the server says it is
@ -30,46 +31,53 @@ export function DealModal() {
const courier = content.shipModel("courier")!;
return (
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="deal-title"
className="w-full max-w-md rounded-xl border border-amber-500/50 bg-slate-900 p-4"
<Sheet
titleId="deal-title"
eyebrow="Incoming transmission"
title="An offer from Halvorsen"
tone="amber"
layer="z-30"
>
<h2 id="deal-title" className="text-lg font-semibold text-amber-300">
An offer from Halvorsen
</h2>
<p className="mt-2 text-sm text-slate-300">
<div className="mt-3 flex items-center gap-3">
<Art sprite={courier.sprite} className="size-20 shrink-0 -rotate-12" />
<p className="text-sm text-ink-200">
Old Halvorsen is retiring. He&apos;ll sell you his Courier and his Earth bay. Pay him back
out of every job.
</p>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
<dt className="text-slate-400">You get</dt>
</div>
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-space-950/60 p-3 text-sm ring-1 ring-amber-400/25">
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
You get
</dt>
<dd>A {courier.name} and a bay at Earth</dd>
<dt className="text-slate-400">Loan</dt>
<dd aria-label="Loan amount" className="tabular-nums">
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Loan</dt>
<dd
aria-label="Loan amount"
className="font-display font-semibold text-amber-200 tabular-nums"
>
{formatCredits(dealLoanAmount)}
</dd>
<dt className="text-slate-400">Repayment</dt>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Repayment
</dt>
<dd aria-label="Repayment share">
{Math.round(loanRepaymentShare * 100)}% of every payout until it&apos;s paid off
</dd>
</dl>
{accept.isError && (
<p role="alert" className="mt-3 text-sm text-red-400">
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(accept.error)}
</p>
)}
<button
type="button"
<Button
size="lg"
block
onClick={() => accept.mutate()}
disabled={accept.isPending}
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-60"
className="mt-5"
>
Shake on it
</button>
</div>
</div>
</Button>
</Sheet>
);
}

View File

@ -1,6 +1,7 @@
import { content } from "@deadhead/content";
import { formatCredits } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Panel } from "../ui";
/** How many unclaimed goals HQ shows at once (Step 10A.15). */
export const GOALS_SHOWN = 3;
@ -13,46 +14,61 @@ export function GoalsCard() {
if (next.length === 0) return null;
return (
<section
id="goals"
aria-labelledby="goals-title"
className="rounded-lg border border-slate-800 p-3"
>
<h2 id="goals-title" className="font-semibold">
<Panel as="section" feature id="goals" aria-labelledby="goals-title">
<h2 id="goals-title" className="flex items-center gap-2 font-display text-lg font-bold">
<Icon name="target" className="size-5 text-amber-300" />
Goals
</h2>
<ul className="mt-2 space-y-2">
<ul className="mt-3 space-y-2">
{next.map((goal) => {
const details = content.onboardingGoal(goal.id);
if (!details) return null;
const ready = goal.status === "ready";
return (
<li key={goal.id} className="flex items-center justify-between gap-2 text-sm">
<span className={ready ? "text-emerald-300" : "text-slate-300"}>
<li
key={goal.id}
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
ready
? "border-emerald-400/50 bg-emerald-500/10 shadow-glow-emerald"
: "border-space-700 bg-space-950/40"
}`}
>
<span className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className={`grid size-5 shrink-0 place-items-center rounded-full ring-1 ${
ready ? "bg-emerald-400 text-space-950 ring-emerald-300" : "ring-space-500"
}`}
>
{ready && <Icon name="check" className="size-3.5" />}
</span>
<span className={ready ? "text-emerald-200" : "text-ink-200"}>
{details.title}
<span className="ml-2 text-xs text-slate-400">
<span className="ml-2 font-display text-xs font-semibold whitespace-nowrap text-amber-300/90">
+{formatCredits(details.reward)}
</span>
</span>
</span>
{ready && (
<button
type="button"
<Button
variant="success"
size="sm"
disabled={claim.isPending}
onClick={() => claim.mutate({ goalId: goal.id })}
className="shrink-0 rounded-md bg-emerald-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
className="shrink-0 animate-glow"
>
Claim
</button>
</Button>
)}
</li>
);
})}
</ul>
{claim.isError && (
<p role="alert" className="mt-2 text-xs text-red-400">
<p role="alert" className="mt-2 text-xs text-rose-300">
{errorMessage(claim.error)}
</p>
)}
</section>
</Panel>
);
}

View File

@ -2,6 +2,7 @@ import { content } from "@deadhead/content";
import { useState } from "react";
import { formatCredits } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Meter, Panel } from "../ui";
/** Why a pay-down amount cannot be sent, or null if it can. */
export function payDownProblem(amount: number, credits: number, balance: number): string | null {
@ -23,28 +24,24 @@ export function LoanCard() {
const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);
return (
<div className="rounded-lg border border-slate-700 p-3">
<Panel>
<div className="flex items-baseline justify-between gap-2">
<h2 className="font-semibold">Loan from Halvorsen</h2>
<span aria-label="Loan balance" className="tabular-nums">
<h2 className="font-display text-lg font-bold">Loan from Halvorsen</h2>
<span
aria-label="Loan balance"
className="font-display font-semibold text-amber-200 tabular-nums"
>
{formatCredits(loanBalance)} left
</span>
</div>
<p className="mt-1 text-xs text-slate-400">
<p className="mt-1 text-xs text-ink-400">
{Math.round(content.config.loanRepaymentShare * 100)}% of every payout goes to the lender
until it&apos;s paid off.
</p>
<div
role="progressbar"
aria-label="Loan paid off"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={paidOff}
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
>
<div className="h-full bg-emerald-400" style={{ width: `${paidOff}%` }} />
</div>
<p className="mt-1 text-right text-xs text-slate-400">{paidOff}% paid off</p>
<Meter label="Loan paid off" value={paidOff} tone="emerald" className="mt-3" />
<p className="mt-1 text-right font-display text-xs text-emerald-300/80">
{paidOff}% paid off
</p>
<form
className="mt-2 flex items-center gap-2"
@ -62,22 +59,18 @@ export function LoanCard() {
placeholder="Amount"
value={input}
onChange={(e) => setInput(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
className="min-h-11 min-w-0 flex-1 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display tabular-nums placeholder:text-ink-500 focus:border-amber-400 focus:outline-none"
/>
<button
type="submit"
disabled={input === "" || problem !== null || repay.isPending}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
>
<Button type="submit" disabled={input === "" || problem !== null || repay.isPending}>
Pay down
</button>
</Button>
</form>
{problem && <p className="mt-1 text-xs text-red-400">{problem}</p>}
{problem && <p className="mt-1 text-xs text-rose-300">{problem}</p>}
{repay.isError && (
<p role="alert" className="mt-1 text-xs text-red-400">
<p role="alert" className="mt-1 text-xs text-rose-300">
{errorMessage(repay.error)}
</p>
)}
</div>
</Panel>
);
}

View File

@ -0,0 +1,84 @@
import { content } from "@deadhead/content";
import { locationName } from "../format";
import { Art, Icon } from "../ui";
/**
* Origin art → flight line → destination art (CR-005). With `progress` (0–1)
* the line fills and the ship's art rides along it. Decorative: the route is
* always in text nearby, so the strip is hidden from screen readers.
*/
export function RouteStrip({
originId,
destinationId,
progress,
shipSprite,
size = "md",
}: {
originId: string;
destinationId: string;
progress?: number;
shipSprite?: string;
size?: "sm" | "md";
}) {
const percent = progress === undefined ? null : Math.max(0, Math.min(1, progress)) * 100;
return (
<div aria-hidden="true" className="flex items-center gap-1">
<Stop id={originId} size={size} />
<div className="relative h-8 flex-1">
<div className="absolute inset-x-0 top-1/2 border-t-2 border-dashed border-cyan-400/35" />
{percent === null ? (
<Icon
name="arrow"
className="absolute top-1/2 left-1/2 size-4 -translate-1/2 rounded-full bg-space-900 text-cyan-300"
/>
) : (
<>
<div
className="absolute top-1/2 left-0 h-0.5 -translate-y-1/2 rounded-full bg-linear-to-r from-cyan-500/40 to-cyan-300 shadow-glow-cyan"
style={{ width: `${percent}%` }}
/>
<span
className="absolute top-1/2 -translate-1/2 transition-[left] duration-1000"
style={{ left: `${percent}%` }}
>
{shipSprite ? (
<Art
sprite={shipSprite}
className="size-8 rotate-90 drop-shadow-[0_0_6px_rgb(34_211_238/0.7)]"
/>
) : (
<span className="block size-2.5 rounded-full bg-cyan-200 shadow-glow-cyan" />
)}
</span>
</>
)}
</div>
<Stop id={destinationId} size={size} />
</div>
);
}
function Stop({ id, size }: { id: string; size: "sm" | "md" }) {
const sprite = content.location(id)?.sprite;
return (
<div className={`flex shrink-0 flex-col items-center ${size === "sm" ? "w-12" : "w-16"}`}>
<div
className={`grid place-items-center ${size === "sm" ? "size-7" : "size-10"} rounded-full`}
>
{sprite ? (
<Art
sprite={sprite}
className={`${size === "sm" ? "size-7" : "size-10"} drop-shadow-[0_0_8px_rgb(125_211_252/0.35)]`}
/>
) : (
<span className="size-3 rounded-full bg-ink-400" />
)}
</div>
{/* CSS-drawn label: the route is already in text nearby, so keep it out of the DOM. */}
<span
data-label={locationName(id)}
className="mt-0.5 max-w-full truncate font-display text-[10px] font-semibold tracking-wider text-ink-300 uppercase after:content-[attr(data-label)]"
/>
</div>
);
}

View File

@ -1 +1,107 @@
@import "tailwindcss";
@import "@fontsource/chakra-petch/latin-500.css";
@import "@fontsource/chakra-petch/latin-600.css";
@import "@fontsource/chakra-petch/latin-700.css";
@import "@fontsource/barlow/latin-400.css";
@import "@fontsource/barlow/latin-500.css";
@import "@fontsource/barlow/latin-600.css";
/* "Freight terminal HUD" theme (CR-005, decisions 0.5.47–0.5.48). */
@theme {
--font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif;
--font-display: "Chakra Petch", "Barlow", ui-sans-serif, system-ui, sans-serif;
/* Surfaces, darkest first. */
--color-space-950: #03050d;
--color-space-900: #080d1d;
--color-space-850: #0c132a;
--color-space-800: #111a36;
--color-space-700: #1a2548;
--color-space-600: #26345f;
--color-space-500: #3a4a7c;
/* Text. */
--color-ink-50: #f2f5ff;
--color-ink-200: #c9d3ef;
--color-ink-300: #a3afd3;
--color-ink-400: #7e8bb3;
--color-ink-500: #5c688f;
--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-emerald: 0 0 0 1px rgb(52 211 153 / 0.4), 0 0 22px -4px rgb(52 211 153 / 0.55);
--shadow-panel: 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-glow: glow 2.4s ease-in-out infinite;
--animate-sheet: sheet 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both;
@keyframes rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes glow {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
@keyframes sheet {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: none;
}
}
}
@layer base {
html {
color-scheme: dark;
}
body {
background-color: var(--color-space-950);
color: var(--color-ink-50);
font-family: var(--font-sans);
-webkit-tap-highlight-color: transparent;
}
/* Every screen sits in space: the map background, dimmed (decision 0.5.47). */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, rgb(56 189 248 / 0.1), transparent 70%),
linear-gradient(180deg, rgb(3 5 13 / 0.5), rgb(3 5 13 / 0.82)),
url("/assets/map-gen/bg.webp") center / cover no-repeat,
var(--color-space-950);
}
:focus-visible {
outline: 2px solid var(--color-amber-400);
outline-offset: 2px;
}
}
/* Motion is decoration only: switch it off when asked. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@ -1,7 +1,8 @@
import type { ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
import { GameClock } from "../clock/GameClock";
import { SCREENS } from "../routes";
import { SCREENS, type ScreenPath } from "../routes";
import { Icon } from "../ui";
export interface LayoutProps {
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
@ -9,44 +10,155 @@ export interface LayoutProps {
clock?: ReactNode;
/** Shown under the header, e.g. the offline notice. */
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 (
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
<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">
<span className="truncate text-sm font-bold tracking-wide">DEADHEAD</span>
<div className="flex shrink-0 items-center gap-3 text-sm tabular-nums">
<span aria-label="Credits">{credits}</span>
<span aria-label="Game clock" className="text-slate-400">
<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 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">
<div className="flex min-w-0 items-center gap-2">
<LogoMark />
<span className="hidden font-display text-lg font-bold tracking-[0.28em] min-[420px]:inline">
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}
</span>
</div>
</div>
</header>
{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 />
</main>
<nav
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) => (
<NavLink
<NavTab
key={screen.path}
to={screen.path}
end={screen.path === "/"}
className={({ isActive }) =>
`truncate px-1 py-3 text-center text-xs ${isActive ? "font-semibold text-amber-400" : "text-slate-400"}`
}
>
{screen.label}
</NavLink>
path={screen.path}
label={screen.label}
icon={screen.icon}
badge={attention[screen.path] ?? 0}
/>
))}
</nav>
</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

@ -157,7 +157,10 @@ export function MapCanvas({
className="relative h-full w-full touch-none overflow-hidden"
>
{loading && (
<p role="status" className="absolute inset-x-0 top-4 text-center text-sm text-slate-400">
<p
role="status"
className="absolute inset-x-0 top-4 text-center font-display text-sm tracking-widest text-ink-400 uppercase"
>
Loading map…
</p>
)}

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 = [
{ 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"];

View File

@ -1,6 +1,7 @@
import type { Contract } from "@deadhead/schema";
import { useState } from "react";
import { useLoadedPlayer } from "../state/actions";
import { Screen, ScreenHeader } from "../ui";
import { LocationBoards } from "./LocationBoards";
import { DispatchSheet } from "./DispatchSheet";
import { TutorialBoard } from "./TutorialBoard";
@ -10,10 +11,14 @@ export function Contracts() {
const state = useLoadedPlayer();
const [selected, setSelected] = useState<Contract | null>(null);
return (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Contracts</h1>
<Screen className="max-w-4xl">
<ScreenHeader
title="Contracts"
eyebrow={state.tutorial ? "Halvorsen Lunar Freight" : "Freight market"}
icon="jobs"
/>
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
</section>
</Screen>
);
}

View File

@ -26,6 +26,8 @@ import {
import { requirementsText } from "./LocationBoards";
import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Sheet } from "../ui";
/** Short player-facing reasons a ship+pilot cannot take a contract. */
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
@ -107,6 +109,8 @@ export interface PairOption {
shipId: string;
shipName: string;
modelName: string;
/** The model's art (decision 0.5.50). */
modelSprite: string;
pilotId: string | null;
pilotName: string | null;
/** Why this pair cannot take the contract; empty when it can. */
@ -186,6 +190,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
shipId: ship.id,
shipName: ship.name,
modelName: model.name,
modelSprite: model.sprite,
pilotId: pilot?.id ?? null,
pilotName: pilot?.name ?? null,
problems,
@ -223,119 +228,129 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
};
return (
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="dispatch-title"
className="max-h-[85dvh] w-full max-w-md overflow-y-auto rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
<Sheet
titleId="dispatch-title"
eyebrow="Dispatch"
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
>
<h2 id="dispatch-title" className="text-lg font-semibold">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
</h2>
<p aria-label="Job details" className="text-sm text-slate-400">
<p aria-label="Job details" className="mt-1 text-sm text-ink-300">
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
{formatCredits(contract.payout)}
{" · "}
{requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
</p>
<div className="mt-3">
<RouteStrip originId={contract.originId} destinationId={contract.destinationId} />
</div>
{options.length === 0 ? (
<p className="mt-3 text-sm text-slate-400">
<p className="mt-3 text-sm text-ink-400">
No idle ships at {locationName(contract.originId)}.
</p>
) : (
<ul className="mt-3 space-y-2">
{options.map((option) => (
{options.map((option) => {
const blocked = option.problems.length > 0;
const picked = chosen === option.shipId;
return (
<li key={option.shipId}>
<label
aria-disabled={option.problems.length > 0}
className={`block rounded-lg border p-3 text-sm ${
option.problems.length > 0
? "border-slate-800 opacity-60"
: chosen === option.shipId
? "border-amber-400"
: "border-slate-700"
aria-disabled={blocked}
className={`block rounded-xl border p-3 text-sm transition ${
blocked
? "border-space-700 bg-space-950/40 opacity-70"
: picked
? "cursor-pointer border-amber-400 bg-amber-500/10 shadow-glow-amber"
: "cursor-pointer border-space-600 bg-space-900/60 hover:border-space-500"
}`}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<input
type="radio"
name="pair"
value={option.shipId}
disabled={option.problems.length > 0}
checked={chosen === option.shipId}
disabled={blocked}
checked={picked}
onChange={() => setChosen(option.shipId)}
className="size-4 shrink-0 accent-amber-400"
/>
<span className="font-medium">{option.shipName}</span>
<span className="text-slate-400">
<Art sprite={option.modelSprite} className="size-10 shrink-0" />
<span className="min-w-0">
<span className="block font-display font-bold tracking-wide">
{option.shipName}
</span>
<span className="block text-xs text-ink-400">
{option.modelName} · {option.pilotName ?? "no pilot"}
</span>
</span>
</div>
{option.problems.length > 0 ? (
<ul aria-label="Problems" className="mt-1 list-disc pl-5 text-red-400">
{blocked ? (
<ul aria-label="Problems" className="mt-2 space-y-0.5 pl-1 text-rose-300">
{option.problems.map((p) => (
<li key={p}>{p}</li>
<li
key={p}
className="flex gap-1.5 before:text-rose-400 before:content-['✕']"
>
{p}
</li>
))}
</ul>
) : (
<dl className="mt-1 grid grid-cols-2 gap-x-3 text-slate-300">
<dt>Trip</dt>
<dd className="tabular-nums">{formatDuration(option.tripMs)}</dd>
<dt>Fuel</dt>
<dd className="tabular-nums">{formatCredits(option.fuelCost)}</dd>
<dt>Net profit</dt>
<dd className="tabular-nums">{formatCredits(option.netProfit)}</dd>
<dt>ETA (game)</dt>
<dd className="tabular-nums">
{formatGameTime(realToGame(option.arrivalAt, content.config))}
</dd>
<dt>ETA (real)</dt>
<dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd>
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
<Cell label="Trip" value={formatDuration(option.tripMs)} />
<Cell label="Fuel" value={formatCredits(option.fuelCost)} />
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
<Cell
label="ETA (game)"
value={formatGameTime(realToGame(option.arrivalAt, content.config))}
/>
<Cell label="ETA (real)" value={formatLocalTime(option.arrivalAt)} />
{option.duty && (
<>
<dt>Duty after</dt>
<dd className="tabular-nums">
{formatGameHours(option.duty.after)} of{" "}
{formatGameHours(option.duty.limit)}
</dd>
</>
<Cell
label="Duty after"
value={`${formatGameHours(option.duty.after)} of ${formatGameHours(option.duty.limit)}`}
/>
)}
</dl>
)}
{option.problems.length === 0 && option.duty?.willRest && (
<p className="mt-1 text-amber-300">Will need rest on arrival</p>
{!blocked && option.duty?.willRest && (
<p className="mt-2 text-amber-300">Will need rest on arrival</p>
)}
</label>
</li>
))}
);
})}
</ul>
)}
{dispatch.isError && (
<p role="alert" className="mt-3 text-sm text-red-400">
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(dispatch.error)}
</p>
)}
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg border border-slate-700 py-2"
>
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</button>
<button
type="button"
onClick={confirm}
disabled={!selected || dispatch.isPending}
className="flex-1 rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-40"
>
</Button>
<Button onClick={confirm} disabled={!selected || dispatch.isPending} className="flex-1">
Confirm
</button>
</div>
</Button>
</div>
</Sheet>
);
}
/** One number in a pair's outlook; `dt` directly before `dd` ("Trip2h 05m"). */
function Cell({ label, value, good }: { label: string; value: string; good?: boolean }) {
return (
<div className="rounded-lg bg-space-950/60 px-2 py-1.5 ring-1 ring-space-700">
<dt className="font-display text-[10px] tracking-[0.14em] text-ink-400 uppercase">{label}</dt>
<dd
className={`font-display text-sm font-semibold tabular-nums ${good ? "text-emerald-300" : "text-ink-50"}`}
>
{value}
</dd>
</div>
);
}

View File

@ -14,25 +14,50 @@ import { Countdown } from "../components/Countdown";
import { formatCredits, formatDuration, locationName, shipModelName } from "../format";
import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import {
Art,
Button,
buttonClass,
Icon,
Meter,
Panel,
Pill,
Screen,
ScreenHeader,
Sheet,
} from "../ui";
/** All ships, their pilots and flights; any idle ship can deadhead (Steps 9.6, 10B.8). */
export function Fleet() {
const state = useLoadedPlayer();
const [deadheading, setDeadheading] = useState<Ship | null>(null);
const flying = state.ships.filter((s) => s.status === "in-flight").length;
return (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Fleet</h1>
<Screen className="max-w-4xl">
<ScreenHeader
title="Fleet"
eyebrow={
state.ships.length > 0
? `${state.ships.length} ${state.ships.length === 1 ? "ship" : "ships"} · ${flying} flying`
: "Your ships"
}
icon="fleet"
/>
{state.ships.length === 0 ? (
<p className="text-sm text-slate-400">
<Panel tone="muted" className="flex flex-col items-center gap-3 py-8 text-center">
<Art sprite="ship-courier" className="size-20 opacity-60" />
<p className="text-sm text-ink-300">
No ships yet —{" "}
<Link to="/market" className="text-amber-400 underline">
<Link to="/market" className="font-semibold text-amber-300 underline">
visit the Market
</Link>
.
</p>
</Panel>
) : (
<ul className="space-y-2">
<ul className="grid gap-3 md:grid-cols-2">
{state.ships.map((ship) => (
<ShipRow
key={ship.id}
@ -45,7 +70,7 @@ export function Fleet() {
</ul>
)}
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
</section>
</Screen>
);
}
@ -58,55 +83,79 @@ function ShipRow(props: {
const { ship, dispatch, pilotName } = props;
const now = useClock((s) => s.now);
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
const model = shipModel(ship.modelId);
return (
<li className="rounded-lg border border-slate-800 p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{ship.name}</span>
<span className="text-sm text-slate-400">{shipModelName(ship.modelId)}</span>
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="relative grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
<Art
sprite={model.sprite}
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
/>
</div>
<div className="mt-1 text-sm text-slate-300">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<span className="truncate font-display text-lg font-bold tracking-wide">
{ship.name}
</span>
{dispatch ? (
<Pill tone="cyan" icon="send">
In flight
</Pill>
) : (
<Pill tone="emerald">Idle</Pill>
)}
</div>
<div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div>
<div className="mt-1 text-sm text-ink-200">
{dispatch
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
: `At ${locationName(ship.locationId ?? "")}`}
{" · "}
<span className="text-slate-400">{pilotName ?? "No pilot"}</span>
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
{pilotName ?? "No pilot"}
</span>
</div>
</div>
</div>
{dispatch && (
<div className="mt-2">
<div
role="progressbar"
aria-label={`${ship.name} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="h-2 overflow-hidden rounded bg-slate-800"
>
<div className="h-full bg-sky-400" style={{ width: `${progress}%` }} />
</div>
<div className="mt-1 text-right text-xs text-slate-400">
Arrives in <Countdown to={dispatch.arrivalAt} />
<div>
<Meter label={`${ship.name} progress`} value={progress} tone="cyan" />
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
<span>{progress}%</span>
<span>
Arrives in{" "}
<span className="text-cyan-200">
<Countdown to={dispatch.arrivalAt} />
</span>
</span>
</div>
</div>
)}
{ship.status === "idle" && ship.locationId && (
{ship.status === "idle" && (
<div className="mt-auto flex flex-wrap gap-2">
{ship.locationId && (
<Link
to={`/contracts?location=${ship.locationId}`}
className="mt-2 mr-2 inline-block rounded-md border border-slate-700 px-3 py-1 text-sm"
className={`${buttonClass("primary", "sm")} flex-1 basis-40`}
>
<Icon name="jobs" className="size-4" />
Find work here
</Link>
)}
{ship.status === "idle" && (
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={props.onDeadhead}
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
className="flex-1 basis-40"
>
<Icon name="send" className="size-4" />
Deadhead (fly empty)
</button>
</Button>
</div>
)}
</li>
</Panel>
);
}
@ -165,21 +214,16 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
});
return (
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="deadhead-title"
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
<Sheet
titleId="deadhead-title"
eyebrow="Fly empty"
title={`Deadhead ${ship.name}`}
subtitle={`Fly empty from ${locationName(origin)} to another place you know. You pay fuel only.`}
>
<h2 id="deadhead-title" className="text-lg font-semibold">
Deadhead {ship.name}
</h2>
<p className="text-sm text-slate-400">
Fly empty from {locationName(origin)} to another place you know. You pay fuel only.
</p>
<ul className="mt-3 space-y-2">
{options.map((option) => (
<ul className="mt-4 space-y-2">
{options.map((option) => {
const sprite = content.location(option.id)?.sprite;
return (
<li key={option.id}>
<button
type="button"
@ -196,34 +240,35 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
{ onSuccess: onClose },
)
}
className="w-full rounded-lg border border-slate-700 p-3 text-left disabled:opacity-50"
className="flex w-full items-center gap-3 rounded-xl border border-space-600 bg-space-900/60 p-3 text-left transition hover:border-amber-400/70 hover:bg-amber-500/10 active:scale-[0.99] disabled:pointer-events-none disabled:opacity-55"
>
{sprite && <Art sprite={sprite} className="size-10 shrink-0" />}
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="font-medium">{locationName(option.id)}</span>
<span className="text-sm tabular-nums text-slate-300">
<span className="font-display font-bold tracking-wide">
{locationName(option.id)}
</span>
<span className="font-display text-sm text-ink-300 tabular-nums">
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
</span>
</span>
{option.problem && (
<span className="mt-1 block text-sm text-red-400">{option.problem}</span>
<span className="mt-0.5 block text-sm text-rose-300">{option.problem}</span>
)}
</span>
</button>
</li>
))}
);
})}
</ul>
{reposition.isError && (
<p role="alert" className="mt-3 text-sm text-red-400">
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(reposition.error)}
</p>
)}
<button
type="button"
onClick={onClose}
className="mt-4 w-full rounded-lg border border-slate-700 py-2"
>
<Button variant="secondary" block onClick={onClose} className="mt-4">
Cancel
</button>
</div>
</div>
</Button>
</Sheet>
);
}

View File

@ -1,13 +1,16 @@
import { content } from "@deadhead/content";
import type { TutorialState } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim";
import { dispatchProgress, realToGame } from "@deadhead/sim";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown";
import { GoalsCard } from "../components/GoalsCard";
import { LoanCard } from "../components/LoanCard";
import { RouteStrip } from "../components/RouteStrip";
import { formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions";
import { buttonClass, Eyebrow, Icon, Panel, Screen, ScreenHeader, StatTile } from "../ui";
/** What the player needs to do next to reach the deal (decision 0.5.30). */
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
@ -32,72 +35,130 @@ export function HQ() {
const resting = state.pilots
.filter((p) => p.resting && p.restEndsAt !== null)
.sort((a, b) => a.restEndsAt! - b.restEndsAt!);
const nextShip = next?.tutorial
? "ship-company-courier"
: content.shipModel(state.ships.find((s) => s.id === next?.shipId)?.modelId ?? "")?.sprite;
return (
<section className="space-y-4 p-4">
<h1 className="text-xl font-semibold">HQ</h1>
<Screen className="max-w-5xl">
<ScreenHeader title="HQ" eyebrow={state.company.name} icon="hq" />
<div className="grid grid-cols-2 gap-3">
<Stat label="Credits" value={formatCredits(state.company.credits)} />
<Stat label="Game date" value={formatGameTime(realToGame(now, content.config))} />
<Stat label="Ships idle" value={String(idle)} />
<Stat label="Ships in flight" value={String(flying)} />
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile
label="Credits"
value={formatCredits(state.company.credits)}
icon="credits"
tone="amber"
/>
<StatTile
label="Game date"
value={formatGameTime(realToGame(now, content.config))}
icon="clock"
/>
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
</div>
<div className="rounded-lg border border-slate-800 p-3">
<h2 className="text-sm font-semibold text-slate-300">Next arrival</h2>
{next ? (
<p className="mt-1">
{locationName(next.originId)} → {locationName(next.destinationId)} in{" "}
<Countdown to={next.arrivalAt} />
</p>
) : (
<p className="mt-1 text-sm text-slate-400">Nothing in flight.</p>
)}
</div>
{!state.tutorial && (
<div aria-label="Pilot rest" className="rounded-lg border border-slate-800 p-3">
<h2 className="text-sm font-semibold text-slate-300">Pilots resting: {resting.length}</h2>
{resting[0] ? (
<p className="mt-1">
{resting[0].name} wakes in <Countdown to={resting[0].restEndsAt!} done="now" />
</p>
) : (
<p className="mt-1 text-sm text-slate-400">Everyone is ready to fly.</p>
)}
</div>
)}
{!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-4">
{state.tutorial && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
<h2 className="font-semibold text-amber-300">Flying for Halvorsen Lunar Freight</h2>
<p aria-label="Company runs" className="mt-1 text-sm text-slate-300">
<Panel feature tone="amber">
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
Flying for Halvorsen Lunar Freight
</h2>
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
{runsBeforeDeal}
</p>
<p aria-label="Next step" className="mt-1 text-xs text-slate-400">
<RunPips done={state.tutorial.runsCompleted} total={runsBeforeDeal} />
<p aria-label="Next step" className="mt-2 text-sm text-ink-300">
{tutorialHint(state.tutorial, runsBeforeDeal)}
</p>
</div>
{!state.tutorial.runInFlight && !state.tutorial.dealAvailable && (
<Link to="/contracts" className={`mt-3 ${buttonClass("primary", "md", true)}`}>
<Icon name="send" className="size-4" />
Fly a company run
</Link>
)}
</Panel>
)}
{!state.tutorial && <GoalsCard />}
<Panel feature tone={next ? "cyan" : "default"}>
<h2 className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase">
Next arrival
</h2>
{next ? (
<>
<div className="mt-2">
<RouteStrip
originId={next.originId}
destinationId={next.destinationId}
progress={dispatchProgress(next, now)}
shipSprite={nextShip}
/>
</div>
<p className="mt-2 flex items-baseline justify-between gap-2">
<span className="text-sm text-ink-200">
{locationName(next.originId)} → {locationName(next.destinationId)} in
</span>
<span className="font-display text-2xl font-bold text-cyan-200">
<Countdown to={next.arrivalAt} />
</span>
</p>
</>
) : (
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
)}
</Panel>
{!state.tutorial && (
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
<div className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
<Icon name="rest" className="size-5" />
</span>
<div className="min-w-0">
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
Pilots resting: {resting.length}
</h2>
{resting[0] ? (
<p className="text-sm text-sky-200">
{resting[0].name} wakes in{" "}
<Countdown to={resting[0].restEndsAt!} done="now" />
</p>
) : (
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
)}
</div>
</div>
</Panel>
)}
{!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
)}
</div>
<div className="space-y-4">
{!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</section>
</div>
</div>
</Screen>
);
}
function Stat({ label, value }: { label: string; value: string }) {
/** One pip per company run toward the deal. */
function RunPips({ done, total }: { done: number; total: number }) {
return (
<div className="rounded-lg border border-slate-800 p-3">
<div className="text-xs text-slate-400">{label}</div>
<div className="mt-1 font-semibold tabular-nums">{value}</div>
<div aria-hidden="true" className="mt-2 flex gap-1.5">
{Array.from({ length: total }, (_, i) => (
<span
key={i}
className={`h-2 flex-1 rounded-full ${i < done ? "bg-amber-400 shadow-glow-amber" : "bg-space-700"}`}
/>
))}
</div>
);
}

View File

@ -17,6 +17,9 @@ import { distanceAt } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet";
import { DeadheadPicker } from "./Fleet";
import { content } from "@deadhead/content";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Icon, Panel, Pill } from "../ui";
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
@ -54,23 +57,29 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
return (
<div className="space-y-3">
<div role="group" aria-label="Location" className="flex flex-wrap gap-2">
<div className="space-y-4">
<div
role="group"
aria-label="Location"
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1"
>
{discovered.map((id) => {
const idle = idleAt(id);
const sprite = content.location(id)?.sprite;
return (
<button
key={id}
type="button"
aria-pressed={id === locationId}
onClick={() => setParams({ location: id }, { replace: true })}
className="flex items-center gap-1.5 rounded-full border border-slate-700 px-3 py-1 text-sm aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-200"
className="flex shrink-0 items-center gap-2 rounded-full border border-space-600 bg-space-900/70 py-1 pr-3 pl-1 font-display text-sm font-semibold tracking-wide text-ink-300 backdrop-blur transition hover:border-space-500 hover:text-ink-50 aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-100 aria-pressed:shadow-glow-amber"
>
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
{locationName(id)}
{idle > 0 && (
<span
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
className="rounded-full bg-sky-500/25 px-1.5 text-xs tabular-nums text-sky-200"
className="grid h-5 min-w-5 place-items-center rounded-full bg-cyan-400/20 px-1.5 text-xs text-cyan-200 tabular-nums ring-1 ring-cyan-400/40"
>
{idle}
</span>
@ -80,7 +89,8 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
})}
</div>
{idleAt(locationId) === 0 && (
<p className="text-sm text-slate-400">
<p className="flex items-center gap-2 rounded-lg border border-space-700 bg-space-900/60 px-3 py-2 text-sm text-ink-300">
<Icon name="alert" className="size-4 shrink-0 text-amber-300" />
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
</p>
)}
@ -108,77 +118,97 @@ function LocationBoard({
},
});
if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>;
if (board.isPending) return <p className="text-ink-400">Loading contracts…</p>;
if (board.isError) {
return (
<div role="alert" className="text-red-400">
<div role="alert" className="flex items-center gap-3 text-rose-300">
Could not load contracts.{" "}
<button type="button" className="underline" onClick={() => void board.refetch()}>
<Button variant="danger" size="sm" onClick={() => void board.refetch()}>
Retry
</button>
</Button>
</div>
);
}
const refresh = (
<p aria-label="Board refresh" className="text-sm text-slate-400">
<p
aria-label="Board refresh"
className="flex items-center gap-1.5 font-display text-xs tracking-wider text-ink-400 uppercase"
>
<Icon name="clock" className="size-3.5 text-cyan-300" />
New contracts in{" "}
<span className="text-cyan-200">
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
</p>
);
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
if (board.data.contracts.length === 0) {
return (
<div className="space-y-2">
<div className="space-y-3">
{refresh}
{stuck}
<p className="text-sm text-slate-400">No contracts left this period.</p>
<Panel tone="muted" className="text-center text-sm text-ink-400">
No contracts left this period.
</Panel>
</div>
);
}
return (
<div className="space-y-2">
<div className="space-y-3">
{refresh}
{stuck}
<ul className="space-y-2">
<ul className="grid gap-3 sm:grid-cols-2">
{board.data.contracts.map((contract) => {
const requirements = requirementsText(contract);
return (
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">
<Panel
as="li"
key={contract.id}
tone={contract.firstContact ? "amber" : "default"}
feature={contract.firstContact}
className="flex flex-col gap-2"
>
<div className="flex items-start justify-between gap-2">
<span className="font-display font-bold tracking-wide">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
{contract.firstContact && (
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
<Pill tone="amber" icon="star" className="ml-2 align-middle">
First contact
</span>
</Pill>
)}
</span>
<span className="font-semibold tabular-nums text-emerald-400">
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
{formatCredits(contract.payout)}
</span>
</div>
<div className="mt-1 text-sm text-slate-400">
<RouteStrip
originId={contract.originId}
destinationId={contract.destinationId}
size="sm"
/>
<div className="flex items-center gap-1.5 text-sm text-ink-300">
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
<span>
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-sm">
<span className="text-slate-400">
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
<Countdown to={contract.expiresAt} done="expired" />
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
<span className="text-ink-400">
<span className={requirements ? "text-amber-200" : "text-ink-300"}>
{requirements ? `Needs ${requirements}` : "No skills needed"}
</span>{" "}
· expires in <Countdown to={contract.expiresAt} done="expired" />
</span>
{onSelect && (
<button
type="button"
onClick={() => onSelect(contract)}
className="shrink-0 rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
>
<Button size="sm" onClick={() => onSelect(contract)} className="shrink-0">
Dispatch
</button>
</Button>
)}
</div>
</li>
</Panel>
);
})}
</ul>
@ -218,37 +248,37 @@ function NothingHere({
{stuck.map((ship) => {
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id)!;
return (
<div
<Panel
key={ship.id}
role="group"
aria-label={`Nothing for ${ship.name}`}
className="rounded-lg border border-sky-900 bg-sky-950/40 p-3 text-sm"
tone="cyan"
className="text-sm"
>
<p>
<p className="flex items-center gap-2 text-cyan-100">
<Icon name="alert" className="size-4 shrink-0 text-cyan-300" />
No job here that {ship.name} and {pilot.name} can take.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<button
type="button"
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={rest.isPending || state.tutorial !== null}
onClick={() => rest.mutate({ pilotId: pilot.id })}
className="rounded-md border border-slate-700 px-3 py-1"
>
<Icon name="rest" className="size-4" />
Rest {pilot.name} — wakes to a new board
</button>
<button
type="button"
onClick={() => setDeadheading(ship)}
className="rounded-md border border-slate-700 px-3 py-1"
>
</Button>
<Button variant="secondary" size="sm" onClick={() => setDeadheading(ship)}>
<Icon name="send" className="size-4" />
Deadhead (fly empty)
</button>
</div>
</Button>
</div>
</Panel>
);
})}
{rest.isError && (
<p role="alert" className="text-sm text-red-400">
<p role="alert" className="text-sm text-rose-300">
{errorMessage(rest.error)}
</p>
)}

View File

@ -4,6 +4,7 @@ import { useClock } from "../clock/clock";
import { cargoName, formatCredits, formatMillionKm } from "../format";
import { distanceAt } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Art, Button, buttonClass, Icon, Panel, Pill, Screen, ScreenHeader } from "../ui";
const TYPE_LABELS: Record<Location["type"], string> = {
"atmospheric-world": "Atmospheric world",
@ -24,72 +25,120 @@ export function Locations() {
const lease = useGameAction("leaseBay");
return (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Locations</h1>
<Screen className="max-w-4xl">
<ScreenHeader title="Locations" eyebrow="Known space" icon="places" />
{lease.isError && (
<p role="alert" className="text-sm text-red-400">
<p role="alert" className="text-sm text-rose-300">
{errorMessage(lease.error)}
</p>
)}
<ul className="space-y-2">
<ul className="grid gap-3 md:grid-cols-2">
{content.locations.map((location) => {
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
if (!discovered) {
return (
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="font-medium">{location.name}</div>
<div className="text-slate-400">Undiscovered — deliver here to unlock.</div>
</li>
<Panel
as="li"
key={location.id}
tone="muted"
className="flex items-center gap-4 text-sm"
>
<div className="relative shrink-0">
<Art sprite={location.sprite} className="size-16 opacity-40 grayscale" />
<Icon
name="lock"
className="absolute top-1/2 left-1/2 size-6 -translate-1/2 text-ink-200"
/>
</div>
<div>
<div className="font-display text-lg font-bold tracking-wide text-ink-300">
{location.name}
</div>
<div className="text-ink-400">Undiscovered — deliver here to unlock.</div>
</div>
</Panel>
);
}
const hasBay = state.bays.some((b) => b.locationId === location.id);
const affordable = state.company.credits >= location.bayLeasePrice;
return (
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{location.name}</span>
<span className="text-slate-400">
<Panel
as="li"
key={location.id}
tone={hasBay ? "emerald" : "default"}
className="flex flex-col text-sm"
>
<div className="flex items-center gap-4">
<Art
sprite={location.sprite}
className="size-20 shrink-0 drop-shadow-[0_0_14px_rgb(125_211_252/0.35)]"
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<span className="font-display text-xl font-bold tracking-wide">
{location.name}
</span>
{hasBay && (
<Pill tone="emerald" icon="hq">
HQ
</Pill>
)}
</div>
<span className="font-display text-xs tracking-wider text-cyan-300/80">
{location.id === "earth"
? "Home"
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
</span>
</div>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-slate-300">
<dt className="text-slate-400">Type</dt>
</div>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg bg-space-950/50 p-2.5 text-ink-200 ring-1 ring-space-700">
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Type
</dt>
<dd>{TYPE_LABELS[location.type]}</dd>
<dt className="text-slate-400">Landing</dt>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Landing
</dt>
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
<dt className="text-slate-400">Produces</dt>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Produces
</dt>
<dd>{location.produces.map(cargoName).join(", ") || "—"}</dd>
<dt className="text-slate-400">Demands</dt>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Demands
</dt>
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
</dl>
<Link
to={`/contracts?location=${location.id}`}
className="mt-2 inline-block font-medium text-amber-400 underline"
className={`mt-3 ${buttonClass("secondary", "sm")}`}
>
<Icon name="jobs" className="size-4" />
View contracts
</Link>
{hasBay ? (
<p className="mt-2 font-medium text-emerald-400">Your HQ is here</p>
<p className="mt-3 flex items-center gap-1.5 font-display font-semibold text-emerald-300">
<Icon name="check" className="size-4" />
Your HQ is here
</p>
) : (
<>
<p className="mt-2 text-slate-400">{HQ_EXPLANATION}</p>
<button
type="button"
<p className="mt-3 text-ink-400">{HQ_EXPLANATION}</p>
<Button
aria-label={`Lease bay at ${location.name}`}
block
disabled={!affordable || lease.isPending}
onClick={() => lease.mutate({ locationId: location.id })}
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
className="mt-2"
>
Lease bay ({formatCredits(location.bayLeasePrice)})
</button>
</Button>
</>
)}
</li>
</Panel>
);
})}
</ul>
</section>
</Screen>
);
}

View File

@ -12,6 +12,7 @@ import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { modelsReachingFromEarth } from "../map/reach";
import { useLoadedPlayer } from "../state/actions";
import { Art, buttonClass, Icon } from "../ui";
const TYPE_LABELS: Record<Location["type"], string> = {
"atmospheric-world": "Atmospheric world",
@ -26,15 +27,16 @@ export function MapScreen() {
const [selection, setSelection] = useState<MapSelection | null>(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.
<section className="relative -mb-20 h-[calc(100dvh-4.875rem-env(safe-area-inset-bottom))]">
// 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).
<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>
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
<button
type="button"
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
className="absolute right-3 top-3 rounded-md bg-slate-900/80 px-2 py-1 text-xs text-slate-300"
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
>
Reset view
</button>
@ -50,10 +52,12 @@ export function MapScreen() {
function Panel({
title,
art,
onClose,
children,
}: {
title: string;
art?: ReactNode;
onClose: () => void;
children: ReactNode;
}) {
@ -61,15 +65,25 @@ function Panel({
<div
role="dialog"
aria-label={title}
className="absolute inset-x-2 bottom-2 rounded-xl border border-slate-700 bg-slate-900/95 p-3 text-sm"
className="absolute inset-x-2 bottom-2 animate-sheet rounded-2xl border border-cyan-400/40 bg-space-900/90 p-4 text-sm shadow-panel backdrop-blur-xl md:inset-x-auto md:right-4 md:bottom-4 md:w-96"
>
<div className="flex items-start gap-3">
{art}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<h2 className="font-semibold">{title}</h2>
<button type="button" onClick={onClose} aria-label="Close" className="px-2 text-slate-400">
✕
<h2 className="font-display text-lg font-bold tracking-wide">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="-mt-1 -mr-1 grid size-9 shrink-0 place-items-center rounded-lg text-ink-400 hover:bg-white/5 hover:text-ink-50"
>
<Icon name="close" className="size-4" />
</button>
</div>
<div className="mt-1">{children}</div>
<div className="mt-1 space-y-1">{children}</div>
</div>
</div>
</div>
);
}
@ -84,31 +98,46 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
return (
<Panel title={location.name} onClose={onClose}>
<Panel
title={location.name}
onClose={onClose}
art={
<Art
sprite={location.sprite}
className={`size-16 shrink-0 ${discovered ? "" : "opacity-40 grayscale"}`}
/>
}
>
{discovered ? (
<>
<p className="text-slate-300">
<p className="text-ink-200">
{TYPE_LABELS[location.type]} ·{" "}
{location.id === "earth"
? "Home"
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
</p>
<p className="text-slate-400">
<p className="text-ink-400">
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
{location.demands.map(cargoName).join(", ") || "nothing"}
</p>
<p className={hasBay ? "text-emerald-300" : "text-ink-400"}>
{hasBay ? "Your HQ is here." : "No HQ here."}
</p>
<Link
to={`/contracts?location=${location.id}`}
className="mt-2 inline-block font-medium text-amber-400 underline"
className={`mt-2 ${buttonClass("primary", "sm")}`}
>
<Icon name="jobs" className="size-4" />
View contracts
</Link>
<p className="mt-1 text-slate-400">{hasBay ? "Your HQ is here." : "No HQ here."}</p>
</>
) : (
<>
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
<p aria-label="Reachable with" className="mt-1 text-slate-300">
<p className="flex items-center gap-1.5 text-ink-300">
<Icon name="lock" className="size-4 shrink-0" />
Undiscovered — deliver here to unlock.
</p>
<p aria-label="Reachable with" className="text-ink-200">
{reachable.length > 0
? `Reachable with: ${reachable.map((m) => m.name).join(", ")}`
: "No ship can reach it from Earth right now."}
@ -128,16 +157,27 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: ()
? "Company loaner"
: (state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "Ship");
const pilotName = state.pilots.find((p) => p.id === dispatch.pilotId)?.name ?? "Unknown pilot";
const shipSprite = dispatch.tutorial
? "ship-company-courier"
: (content.shipModel(state.ships.find((s) => s.id === dispatch.shipId)?.modelId ?? "")
?.sprite ?? "ship-courier");
return (
<Panel title={shipName} onClose={onClose}>
<p className="text-slate-300">
<Panel
title={shipName}
onClose={onClose}
art={<Art sprite={shipSprite} className="size-14 shrink-0 rotate-45" />}
>
<p className="font-display text-ink-50">
{locationName(dispatch.originId)} → {locationName(dispatch.destinationId)}
</p>
<p className="text-slate-400">Pilot: {pilotName}</p>
<p className="text-slate-400">
Arrives in <Countdown to={dispatch.arrivalAt} /> (
{formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
<p className="text-ink-300">Pilot: {pilotName}</p>
<p className="text-ink-300">
Arrives in{" "}
<span className="font-display text-cyan-200">
<Countdown to={dispatch.arrivalAt} />
</span>{" "}
({formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
</p>
</Panel>
);

View File

@ -2,9 +2,19 @@ import { content, type ShipModel } from "@deadhead/content";
import { useState } from "react";
import { formatCredits, formatMillionKm, locationName } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
const MODELS = content.purchasableShipModels;
const best = {
speed: Math.max(...MODELS.map((m) => m.cruiseSpeedKmPerGameHour)),
capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)),
range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)),
};
/** Range spans 5 to 1,000M km, so its bar uses a log scale to stay readable. */
const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range);
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
export function Market() {
const state = useLoadedPlayer();
@ -12,50 +22,78 @@ export function Market() {
const [buying, setBuying] = useState<ShipModel | null>(null);
return (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Market</h1>
<p className="text-sm text-slate-400">
New ships are delivered with no pilot to one of your HQs.
</p>
<Screen className="max-w-5xl">
<ScreenHeader
title="Market"
eyebrow="Shipyard"
icon="market"
subtitle="New ships are delivered with no pilot to one of your HQs."
/>
{buy.isError && (
<p role="alert" className="text-sm text-red-400">
<p role="alert" className="text-sm text-rose-300">
{errorMessage(buy.error)}
</p>
)}
{buy.isSuccess && (
<p role="status" className="text-sm text-emerald-400">
<p
role="status"
className="flex items-center gap-2 rounded-lg border border-emerald-400/40 bg-emerald-950/50 px-3 py-2 text-sm text-emerald-200"
>
<Icon name="check" className="size-4 shrink-0" />
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
</p>
)}
<ul className="space-y-2">
{content.purchasableShipModels.map((model) => {
<ul className="grid gap-4 md:grid-cols-3">
{MODELS.map((model) => {
const affordable = state.company.credits >= model.price;
return (
<li key={model.id} className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{model.name}</span>
<span className="font-semibold tabular-nums">{formatCredits(model.price)}</span>
<Panel as="li" key={model.id} feature={affordable} className="flex flex-col text-sm">
<div className="relative -mx-4 -mt-4 grid h-40 place-items-center overflow-hidden rounded-t-xl bg-radial from-cyan-400/20 via-space-850/40 to-transparent to-75%">
<div className="absolute inset-x-8 bottom-4 h-3 rounded-[50%] bg-cyan-300/15 blur-md" />
<Art
sprite={model.sprite}
className="relative size-32 rotate-[30deg] drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
/>
</div>
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-0.5 text-slate-300">
<dt className="text-slate-400">Speed</dt>
<dd>{formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h</dd>
<dt className="text-slate-400">Capacity</dt>
<dd>{model.cargoCapacityTonnes} t</dd>
<dt className="text-slate-400">Range</dt>
<dd>{formatMillionKm(model.fuelRangeMillionKm)}</dd>
<dt className="text-slate-400">Landing</dt>
<dd>{LANDING_LABELS[model.landingClass]}</dd>
<div className="mt-2 flex items-baseline justify-between gap-2">
<span className="font-display text-xl font-bold tracking-wide">{model.name}</span>
<span
className={`font-display text-base font-bold tabular-nums ${affordable ? "text-amber-300" : "text-ink-400"}`}
>
{formatCredits(model.price)}
</span>
</div>
<dl className="mt-3 space-y-2 text-ink-200">
<Spec
icon="speed"
label="Speed"
value={`${formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h`}
share={model.cruiseSpeedKmPerGameHour / best.speed}
/>
<Spec
icon="cargo"
label="Capacity"
value={`${model.cargoCapacityTonnes} t`}
share={model.cargoCapacityTonnes / best.capacity}
/>
<Spec
icon="range"
label="Range"
value={formatMillionKm(model.fuelRangeMillionKm)}
share={rangeShare(model.fuelRangeMillionKm)}
/>
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
</dl>
<button
type="button"
<Button
aria-label={`Buy ${model.name}`}
block
disabled={!affordable || state.bays.length === 0 || buy.isPending}
onClick={() => setBuying(model)}
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
className="mt-4"
>
{affordable ? "Buy" : "Not enough credits"}
</button>
</li>
</Button>
</Panel>
);
})}
</ul>
@ -69,7 +107,30 @@ export function Market() {
onClose={() => setBuying(null)}
/>
)}
</section>
</Screen>
);
}
/** One spec line: label and value (dt directly before dd), with a bar relative to the best model. */
function Spec(props: { icon: IconName; label: string; value: string; share?: number }) {
return (
<div>
<div className="flex items-center justify-between gap-2">
<dt className="flex items-center gap-1.5 font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
<Icon name={props.icon} className="size-3.5" />
{props.label}
</dt>
<dd className="font-display font-semibold tabular-nums">{props.value}</dd>
</div>
{props.share !== undefined && (
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
<div
className="h-full rounded-full bg-linear-to-r from-cyan-500 to-sky-300"
style={{ width: `${Math.max(4, props.share * 100)}%` }}
/>
</div>
)}
</div>
);
}
@ -84,52 +145,45 @@ function DeliveryPicker(props: {
const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null);
return (
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="delivery-title"
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
<Sheet
titleId="delivery-title"
eyebrow="Delivery"
title="Deliver to which HQ?"
subtitle={`Your ${props.model.name} will wait there, idle and without a pilot.`}
>
<h2 id="delivery-title" className="text-lg font-semibold">
Deliver to which HQ?
</h2>
<p className="text-sm text-slate-400">
Your {props.model.name} will wait there, idle and without a pilot.
</p>
<fieldset className="mt-3 space-y-2">
<fieldset className="mt-4 space-y-2">
<legend className="sr-only">HQ</legend>
{state.bays.map((bay) => (
{state.bays.map((bay) => {
const sprite = content.location(bay.locationId)?.sprite;
return (
<label
key={bay.id}
className="flex items-center gap-2 rounded-lg border border-slate-700 p-3 text-sm"
className="flex cursor-pointer items-center gap-3 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm transition has-checked:border-amber-400 has-checked:bg-amber-500/10 has-checked:shadow-glow-amber"
>
<input
type="radio"
name="hq"
checked={bayId === bay.id}
onChange={() => setBayId(bay.id)}
className="size-4 accent-amber-400"
/>
{locationName(bay.locationId)}
{sprite && <Art sprite={sprite} className="size-8" />}
<span className="font-display font-semibold">{locationName(bay.locationId)}</span>
</label>
))}
);
})}
</fieldset>
<button
type="button"
<Button
block
disabled={bayId === null || props.pending}
onClick={() => bayId && props.onBuy(bayId)}
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-40"
className="mt-4"
>
Buy for {formatCredits(props.model.price)}
</button>
<button
type="button"
onClick={props.onClose}
className="mt-2 w-full rounded-lg border border-slate-700 py-2"
>
</Button>
<Button variant="secondary" block onClick={props.onClose} className="mt-2">
Cancel
</button>
</div>
</div>
</Button>
</Sheet>
);
}

View File

@ -11,6 +11,7 @@ import {
shipModelName,
} from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
/** XP needed for the pilot's current and next level, or null at max level. */
export function levelProgress(xp: number, level: number) {
@ -25,20 +26,20 @@ export function levelProgress(xp: number, level: number) {
export function Pilots() {
const state = useLoadedPlayer();
return (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Pilots</h1>
<Screen className="max-w-4xl">
<ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
<HirePilot />
{!state.pilots.some((p) => !p.isAvatar) && (
<p className="text-sm text-slate-400">
<p className="text-sm text-ink-400">
No hired pilots yet. Hire one at a bay to crew another ship.
</p>
)}
<ul className="space-y-2">
<ul className="grid gap-3 md:grid-cols-2">
{state.pilots.map((pilot) => (
<PilotCard key={pilot.id} pilot={pilot} />
))}
</ul>
</section>
</Screen>
);
}
@ -50,18 +51,23 @@ function HirePilot() {
const hasBay = state.bays.some((b) => b.locationId === bay);
return (
<div className="rounded-lg border border-slate-800 p-3 text-sm">
<Panel feature tone="amber" className="text-sm">
{state.bays.length === 0 ? (
<p className="text-slate-400">Lease a bay to hire pilots there.</p>
<p className="text-ink-300">Lease a bay to hire pilots there.</p>
) : (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/40">
<Icon name="hire" className="size-5" />
</span>
<label className="flex items-center gap-2">
<span className="text-slate-400">Hire at</span>
<span className="font-display text-xs tracking-wider text-ink-300 uppercase">
Hire at
</span>
<select
aria-label="Hire at"
value={bay}
onChange={(e) => setBay(e.target.value)}
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
className="min-h-11 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display text-sm focus:border-amber-400 focus:outline-none"
>
{state.bays.map((b) => (
<option key={b.id} value={b.locationId}>
@ -70,22 +76,21 @@ function HirePilot() {
))}
</select>
</label>
<button
type="button"
<Button
disabled={!hasBay || state.company.credits < cost || hire.isPending}
onClick={() => hire.mutate({ locationId: bay })}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
className="ml-auto"
>
Hire pilot ({formatCredits(cost)})
</button>
</Button>
</div>
)}
{hire.isError && (
<p role="alert" className="mt-2 text-red-400">
<p role="alert" className="mt-2 text-rose-300">
{errorMessage(hire.error)}
</p>
)}
</div>
</Panel>
);
}
@ -96,23 +101,60 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
const initials = pilot.name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2);
return (
<li className="rounded-lg border border-slate-800 p-3 text-sm">
<Panel as="li" tone={pilot.resting ? "muted" : "default"} className="flex flex-col text-sm">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className={`relative grid size-14 shrink-0 place-items-center rounded-full font-display text-lg font-bold ring-2 ${
pilot.isAvatar
? "bg-amber-500/15 text-amber-200 ring-amber-400/70 shadow-glow-amber"
: "bg-cyan-500/10 text-cyan-200 ring-cyan-400/50"
}`}
>
{initials}
<span className="absolute -right-1 -bottom-1 grid size-6 place-items-center rounded-full bg-space-950 font-display text-[11px] text-ink-50 ring-2 ring-space-600">
{pilot.level}
</span>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">
<span className="truncate font-display text-lg font-bold tracking-wide">
{pilot.name}
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
</span>
<span className="text-slate-400">Level {pilot.level}</span>
<span className="font-display text-xs tracking-wider whitespace-nowrap text-ink-300 uppercase">
Level {pilot.level}
</span>
</div>
<div className="mt-1 text-slate-400">
<div className="mt-1 text-xs text-ink-400">
{progress
? `${progress.xp} / ${progress.needed} XP to next level`
: `${pilot.xp} XP · max level`}
</div>
<div className="mt-1 text-slate-300">
{pilot.status === "in-flight" ? "In flight" : `At ${locationName(pilot.locationId ?? "")}`}
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
<div
className="h-full rounded-full bg-linear-to-r from-violet-500 to-fuchsia-300"
style={{ width: `${progress ? (progress.xp / progress.needed) * 100 : 100}%` }}
/>
</div>
</div>
</div>
<div className="mt-3 text-ink-200">
{pilot.status === "in-flight" ? (
<Pill tone="cyan" icon="send" className="mr-1.5">
In flight
</Pill>
) : (
`At ${locationName(pilot.locationId ?? "")}`
)}
{" · "}
{lockedToLoaner
? "Flying the company loaner"
@ -121,29 +163,41 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
: "No ship"}
</div>
<div className="mt-2">
<div className="text-xs text-slate-400">
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
<div
className={`font-display text-[11px] tracking-[0.14em] uppercase ${pilot.skillPointsAvailable > 0 ? "text-amber-300" : "text-ink-400"}`}
>
Skill points:{" "}
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
</div>
<ul className="mt-1 grid grid-cols-2 gap-1">
<ul className="mt-2 grid grid-cols-2 gap-x-3 gap-y-2">
{content.skills.map((skill) => {
const level = pilot.skills[skill.id] ?? 0;
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
return (
<li key={skill.id} className="flex items-center justify-between gap-1">
<span>
<span className="min-w-0">
<span className="block truncate text-xs text-ink-200">
{skill.name} {level}/{skill.maxLevel}
</span>
<span aria-hidden="true" className="mt-0.5 flex gap-0.5">
{Array.from({ length: skill.maxLevel }, (_, i) => (
<span
key={i}
className={`h-1.5 w-3 rounded-sm ${i < level ? "bg-cyan-300 shadow-[0_0_4px] shadow-cyan-300/70" : "bg-space-700"}`}
/>
))}
</span>
</span>
{canRaise && (
<button
type="button"
aria-label={`Raise ${skill.name} for ${pilot.name}`}
disabled={spend.isPending}
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
className="rounded border border-amber-500 px-1.5 text-amber-300"
className="grid size-8 shrink-0 animate-glow place-items-center rounded-lg border border-amber-400/70 bg-amber-500/15 text-amber-200 transition active:scale-95"
>
+
<Icon name="plus" className="size-4" />
</button>
)}
</li>
@ -151,7 +205,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
})}
</ul>
{spend.isError && (
<p role="alert" className="mt-1 text-red-400">
<p role="alert" className="mt-1 text-rose-300">
{errorMessage(spend.error)}
</p>
)}
@ -162,7 +216,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
<AssignShip pilot={pilot} />
)}
</li>
</Panel>
);
}
@ -179,75 +233,68 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
if (pilot.resting && pilot.restEndsAt !== null) {
return (
<div className="mt-2 text-sky-300" aria-label={`${pilot.name} rest`}>
<div
className="mt-3 flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-950/40 px-3 py-2 text-sky-200"
aria-label={`${pilot.name} rest`}
>
<Icon name="rest" className="size-4 shrink-0" />
<span>
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
</span>
</div>
);
}
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
return (
<div className="mt-2">
<div className="flex items-baseline justify-between text-xs text-slate-400">
<div className="mt-3">
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
<span>Duty</span>
<span>
<span className="tracking-normal normal-case">
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
</span>
</div>
<div
role="progressbar"
aria-label={`${pilot.name} duty`}
aria-valuemin={0}
aria-valuemax={pilot.dutyLimit}
aria-valuenow={used}
className="mt-1 h-1.5 overflow-hidden rounded bg-slate-800"
>
<div
className="h-full bg-amber-400"
style={{ width: `${(used / pilot.dutyLimit) * 100}%` }}
<Meter
label={`${pilot.name} duty`}
value={used}
max={pilot.dutyLimit}
tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
size="sm"
className="mt-1"
/>
</div>
{canRest && pilot.status === "idle" && !confirming && (
<button
type="button"
onClick={() => setConfirming(true)}
className="mt-2 rounded-md border border-slate-700 px-3 py-1"
>
<Button variant="secondary" size="sm" onClick={() => setConfirming(true)} className="mt-3">
<Icon name="rest" className="size-4" />
Rest now
</button>
</Button>
)}
{confirming && (
<div
role="group"
aria-label={`Rest ${pilot.name}?`}
className="mt-2 rounded-md bg-slate-900 p-2"
className="mt-3 rounded-lg border border-sky-400/30 bg-sky-950/40 p-3"
>
<p className="text-slate-300">
<p className="text-ink-200">
{pilot.name} rests for {formatDuration(restRealMs())} and can&apos;t fly until then.
Rest always lasts at least one board refresh, so they wake to new contracts.
</p>
<div className="mt-2 flex gap-2">
<button
type="button"
<Button
size="sm"
disabled={rest.isPending}
onClick={() =>
rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) })
}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
>
Rest {pilot.name}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="rounded-md border border-slate-700 px-3 py-1"
>
</Button>
<Button variant="secondary" size="sm" onClick={() => setConfirming(false)}>
Cancel
</button>
</Button>
</div>
</div>
)}
{rest.isError && (
<p role="alert" className="mt-1 text-red-400">
<p role="alert" className="mt-1 text-rose-300">
{errorMessage(rest.error)}
</p>
)}
@ -266,18 +313,18 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
if (ships.length === 0) {
return (
<p className="mt-2 text-xs text-slate-400">
<p className="mt-3 text-xs text-ink-400">
No uncrewed ships at {locationName(pilot.locationId ?? "")}.
</p>
);
}
return (
<div className="mt-2 flex items-center gap-2">
<div className="mt-3 flex items-center gap-2">
<select
aria-label={`Assign ${pilot.name} to`}
value={shipId}
onChange={(e) => setShipId(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
className="min-w-0 flex-1 min-h-11 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display text-sm focus:border-amber-400 focus:outline-none"
>
<option value="">Choose a ship…</option>
{ships.map((s) => (
@ -286,16 +333,15 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
</option>
))}
</select>
<button
type="button"
<Button
variant="secondary"
disabled={!shipId || assign.isPending}
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
className="rounded-md border border-slate-700 px-3 py-1 disabled:opacity-40"
>
Assign
</button>
</Button>
{assign.isError && (
<p role="alert" className="text-red-400">
<p role="alert" className="text-rose-300">
{errorMessage(assign.error)}
</p>
)}

View File

@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { PLAYER_STATE_KEY } from "../state/player";
import { Art, Button, Icon } from "../ui";
/** First launch: create a guest account (decision: guests first, accounts later). */
export function StartScreen() {
@ -19,25 +20,46 @@ export function StartScreen() {
});
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-slate-950 p-6 text-center text-slate-100">
<div>
<h1 className="text-3xl font-bold tracking-wide">DEADHEAD</h1>
<p className="mt-1 text-slate-400">Space Freight Tycoon</p>
<main className="relative flex min-h-dvh flex-col items-center justify-center gap-8 overflow-hidden p-6 text-center text-ink-50">
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<Art
sprite="sun"
className="absolute -top-24 -right-24 size-72 opacity-80 blur-[1px] sm:size-96"
/>
<Art sprite="body-earth" className="absolute bottom-[14%] -left-10 size-40 sm:size-56" />
<Art
sprite="body-moon"
className="absolute bottom-[34%] left-[30%] size-10 sm:left-[18%]"
/>
<Art
sprite="ship-courier"
className="absolute top-[22%] left-[12%] size-16 rotate-[60deg] drop-shadow-[0_0_10px_rgb(251_191_36/0.5)]"
/>
</div>
<p className="max-w-xs text-sm text-slate-300">
<div className="relative animate-rise">
<h1 className="bg-linear-to-b from-amber-100 via-amber-300 to-amber-600 bg-clip-text font-display text-5xl font-bold tracking-[0.2em] text-transparent drop-shadow-[0_0_24px_rgb(251_191_36/0.35)] sm:text-7xl">
DEADHEAD
</h1>
<p className="mt-2 font-display text-sm tracking-[0.45em] text-cyan-300/80 uppercase">
Space Freight Tycoon
</p>
</div>
<p className="relative max-w-sm animate-rise text-base text-ink-200">
Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
empire across the solar system.
</p>
<button
type="button"
<Button
size="lg"
onClick={() => start.mutate()}
disabled={start.isPending}
className="rounded-lg bg-amber-500 px-6 py-3 font-semibold text-slate-950 disabled:opacity-60"
className="relative animate-rise px-10"
>
<Icon name="send" className="size-5" />
{start.isPending ? "Starting…" : "Start your company"}
</button>
</Button>
{start.isError && (
<p role="alert" className="text-sm text-red-400">
<p role="alert" className="relative text-sm text-rose-300">
Could not start a company. Check your connection and try again.
</p>
)}

View File

@ -6,6 +6,8 @@ import { useClock } from "../clock/clock";
import { formatCredits, formatDuration, locationName, cargoName } from "../format";
import { distanceAt, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Icon, Panel, Pill } from "../ui";
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
@ -23,46 +25,49 @@ export function TutorialBoard() {
const loaner = loanerModel(content);
const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0;
if (board.isPending) return <p className="p-4 text-slate-400">Loading contracts…</p>;
if (board.isPending) return <p className="p-4 text-ink-400">Loading contracts…</p>;
if (board.isError) {
return (
<div role="alert" className="p-4 text-red-400">
<div role="alert" className="flex items-center gap-3 p-4 text-rose-300">
Could not load contracts.{" "}
<button type="button" className="underline" onClick={() => void board.refetch()}>
<Button variant="danger" size="sm" onClick={() => void board.refetch()}>
Retry
</button>
</Button>
</div>
);
}
return (
<div className="space-y-3">
<div className="space-y-4">
{firstRun && (
<div
aria-label="Intro"
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
>
<p className="font-semibold text-amber-200">Halvorsen Lunar Freight needs a pilot.</p>
<p className="text-slate-300">Their ship, your hands. Get the crates to the Moon.</p>
<Panel aria-label="Intro" feature tone="amber" className="flex items-center gap-3 text-sm">
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
<div>
<p className="font-display text-base font-bold text-amber-200">
Halvorsen Lunar Freight needs a pilot.
</p>
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
</div>
</Panel>
)}
<p className="text-sm text-slate-400">
<p className="text-sm text-ink-300">
Company runs in the loaner {loaner.name}. You keep a share of each payout.
</p>
{runInFlight && (
<p className="rounded-lg bg-slate-800 p-2 text-sm">
<p className="flex items-center gap-2 rounded-lg border border-cyan-400/40 bg-cyan-950/50 px-3 py-2 text-sm text-cyan-100">
<Icon name="send" className="size-4 shrink-0 text-cyan-300" />
Your run is in flight. Wait for it to land.
</p>
)}
{fly.isError && (
<p role="alert" className="text-sm text-red-400">
<p role="alert" className="text-sm text-rose-300">
{errorMessage(fly.error)}
</p>
)}
{board.data.contracts.length === 0 && (
<p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p>
<p className="text-sm text-ink-400">No company runs left this period. Check back soon.</p>
)}
<ul className="space-y-2">
<ul className="grid gap-3 sm:grid-cols-2">
{board.data.contracts.map((contract, index) => {
// On the first run, suggest the lightest (fastest) contract.
const suggested = firstRun && index === 0;
@ -81,74 +86,90 @@ export function TutorialBoard() {
contract.tier,
content.config,
);
const border = scripted
? "border-fuchsia-500 bg-fuchsia-500/10"
: suggested
? "border-amber-400 ring-1 ring-amber-400"
: "border-slate-800";
return (
<li
<Panel
as="li"
key={contract.id}
data-suggested={suggested || undefined}
data-scripted={scripted ? true : undefined}
className={`rounded-lg border p-3 ${border}`}
tone={scripted ? "fuchsia" : suggested ? "amber" : "default"}
feature={Boolean(scripted) || suggested}
className={`flex flex-col gap-2 ${suggested ? "shadow-glow-amber" : ""}`}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">
<div className="flex items-start justify-between gap-2">
<span className="font-display font-bold tracking-wide">
{scripted ? (
<span className="text-fuchsia-300">{scripted.name}</span>
<span className="text-fuchsia-200">{scripted.name}</span>
) : (
<>
{locationName(contract.originId)} → {locationName(contract.destinationId)}
</>
)}
{suggested && (
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
<Pill tone="amber" icon="star" className="ml-2 align-middle">
Suggested
</span>
</Pill>
)}
</span>
<span className="text-sm text-slate-400">
<span className="flex items-center gap-1 text-sm whitespace-nowrap text-ink-300">
<Icon name="cargo" className="size-4 text-ink-400" />
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
</span>
</div>
{scripted && (
<p className="mt-1 text-xs text-fuchsia-200">
<p className="text-xs text-fuchsia-200">
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
double pay, no questions asked
</p>
)}
<div className="mt-2 flex items-center justify-between gap-2">
<dl className="grid flex-1 grid-cols-3 text-sm">
<RouteStrip
originId={contract.originId}
destinationId={contract.destinationId}
size="sm"
/>
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2">
<dl className="grid flex-1 grid-cols-3 gap-2 text-sm">
<div>
<dt className="text-xs text-slate-400">Pay</dt>
<dd aria-label="Pay" className="font-semibold tabular-nums text-emerald-400">
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
Pay
</dt>
<dd
aria-label="Pay"
className="font-display text-base font-bold text-emerald-300 tabular-nums"
>
{formatCredits(contract.payout)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400">Time</dt>
<dd aria-label="Trip time" className="tabular-nums">
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
Time
</dt>
<dd aria-label="Trip time" className="font-display text-base tabular-nums">
{formatDuration(tripMs)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400">XP</dt>
<dd aria-label="XP" className="tabular-nums">
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
XP
</dt>
<dd
aria-label="XP"
className="font-display text-base text-violet-300 tabular-nums"
>
+{xp}
</dd>
</div>
</dl>
<button
type="button"
<Button
size="sm"
disabled={runInFlight || fly.isPending}
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-slate-950 disabled:opacity-40"
className="shrink-0"
>
Fly it
</button>
</Button>
</div>
</li>
</Panel>
);
})}
</ul>

View File

@ -0,0 +1,57 @@
import type { ButtonHTMLAttributes } from "react";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
const VARIANTS: Record<ButtonVariant, string> = {
primary:
"bg-linear-to-b from-amber-300 to-amber-500 text-space-950 shadow-glow-amber hover:from-amber-200 hover:to-amber-400",
secondary:
"border border-space-500 bg-space-800/80 text-ink-50 hover:border-amber-400/60 hover:bg-space-700",
ghost: "text-ink-300 hover:bg-white/5 hover:text-ink-50",
success:
"bg-linear-to-b from-emerald-300 to-emerald-500 text-space-950 shadow-glow-emerald hover:from-emerald-200 hover:to-emerald-400",
danger: "border border-rose-500/60 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25",
};
const SIZES: Record<ButtonSize, string> = {
sm: "min-h-9 px-3 text-xs",
md: "min-h-11 px-4 text-sm",
lg: "min-h-13 px-6 text-base",
};
/** Classes for anything that should look like a button, e.g. a router `Link`. */
export function buttonClass(
variant: ButtonVariant = "primary",
size: ButtonSize = "md",
block = false,
): string {
return [
"inline-flex items-center justify-center gap-2 rounded-lg font-display font-semibold uppercase tracking-wider",
"transition duration-150 select-none active:translate-y-px active:scale-[0.98]",
"disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none disabled:saturate-50",
VARIANTS[variant],
SIZES[size],
block ? "w-full" : "",
].join(" ");
}
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Full width. */
block?: boolean;
}
export function Button({
variant,
size,
block,
className = "",
type = "button",
...rest
}: ButtonProps) {
return (
<button type={type} className={`${buttonClass(variant, size, block)} ${className}`} {...rest} />
);
}

74
apps/web/src/ui/Panel.tsx Normal file
View File

@ -0,0 +1,74 @@
import type { ElementType, HTMLAttributes, ReactNode } from "react";
export type Tone = "default" | "amber" | "cyan" | "emerald" | "rose" | "fuchsia" | "muted";
const TONES: Record<Tone, string> = {
default: "border-space-600/70 bg-space-900/75",
muted: "border-space-700/70 bg-space-900/45",
amber: "border-amber-400/45 bg-amber-950/40",
cyan: "border-cyan-400/40 bg-cyan-950/40",
emerald: "border-emerald-400/40 bg-emerald-950/40",
rose: "border-rose-500/45 bg-rose-950/40",
fuchsia: "border-fuchsia-400/50 bg-fuchsia-950/40",
};
const BRACKETS: Record<Tone, string> = {
default: "border-cyan-300/60",
muted: "border-ink-500",
amber: "border-amber-300",
cyan: "border-cyan-300",
emerald: "border-emerald-300",
rose: "border-rose-300",
fuchsia: "border-fuchsia-300",
};
export interface PanelProps extends HTMLAttributes<HTMLElement> {
as?: ElementType;
tone?: Tone;
/** Corner brackets, for panels that should stand out. */
feature?: boolean;
children?: ReactNode;
}
/** A translucent HUD panel (decision 0.5.47). */
export function Panel({
as: Tag = "div",
tone = "default",
feature = false,
className = "",
children,
...rest
}: PanelProps) {
return (
<Tag
className={`relative rounded-xl border p-4 shadow-panel backdrop-blur-md ${TONES[tone]} ${className}`}
{...rest}
>
{feature && <Brackets tone={tone} />}
{children}
</Tag>
);
}
function Brackets({ tone }: { tone: Tone }) {
const c = `pointer-events-none absolute size-3 ${BRACKETS[tone]}`;
return (
<span aria-hidden="true">
<span className={`${c} -top-px -left-px rounded-tl-xl border-t-2 border-l-2`} />
<span className={`${c} -top-px -right-px rounded-tr-xl border-t-2 border-r-2`} />
<span className={`${c} -bottom-px -left-px rounded-bl-xl border-b-2 border-l-2`} />
<span className={`${c} -right-px -bottom-px rounded-br-xl border-r-2 border-b-2`} />
</span>
);
}
/** A small uppercase label above a value or section. */
export function Eyebrow({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<div
className={`font-display text-[11px] font-semibold tracking-[0.18em] text-ink-400 uppercase ${className}`}
>
{children}
</div>
);
}

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>
);
}

71
apps/web/src/ui/Sheet.tsx Normal file
View File

@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import type { Tone } from "./Panel";
const TITLE_TONES: Partial<Record<Tone, string>> = {
amber: "text-amber-300",
emerald: "text-emerald-300",
cyan: "text-cyan-200",
};
const BORDER_TONES: Partial<Record<Tone, string>> = {
amber: "border-amber-400/50",
emerald: "border-emerald-400/40",
cyan: "border-cyan-400/40",
};
/**
* A modal dialog: a bottom sheet on phones, a centred card from `sm` up.
* Labelled by its title, which it renders as an `h2` with `titleId`. Portalled
* to `body`: a transformed ancestor (the screens' rise-in) would otherwise
* become the containing block of `position: fixed`.
*/
export function Sheet({
titleId,
title,
eyebrow,
subtitle,
tone = "default",
layer = "z-20",
children,
}: {
titleId: string;
title: ReactNode;
eyebrow?: ReactNode;
subtitle?: ReactNode;
tone?: Tone;
/** Stacking layer, e.g. `z-30` for a sheet over other sheets. */
layer?: string;
children: ReactNode;
}) {
return createPortal(
<div
className={`fixed inset-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`relative max-h-[88dvh] w-full max-w-md animate-sheet overflow-y-auto rounded-t-2xl border bg-space-900/95 p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl ${BORDER_TONES[tone] ?? "border-space-600"}`}
>
<div
aria-hidden="true"
className="mx-auto -mt-2 mb-3 h-1 w-10 rounded-full bg-space-600 sm:hidden"
/>
{eyebrow && (
<div className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase">
{eyebrow}
</div>
)}
<h2
id={titleId}
className={`font-display text-xl font-bold tracking-wide ${TITLE_TONES[tone] ?? "text-ink-50"}`}
>
{title}
</h2>
{subtitle && <div className="mt-1 text-sm text-ink-300">{subtitle}</div>}
{children}
</div>
</div>,
document.body,
);
}

186
apps/web/src/ui/bits.tsx Normal file
View File

@ -0,0 +1,186 @@
import { useState, type ReactNode } from "react";
import { ART_BASE_URL } from "../map/art/load";
import { Icon, type IconName } from "./icons";
import type { Tone } from "./Panel";
const PILL_TONES: Record<Tone, string> = {
default: "border-space-500 bg-space-800 text-ink-200",
muted: "border-space-600 bg-space-900 text-ink-400",
amber: "border-amber-400/50 bg-amber-500/15 text-amber-200",
cyan: "border-cyan-400/50 bg-cyan-500/15 text-cyan-200",
emerald: "border-emerald-400/50 bg-emerald-500/15 text-emerald-200",
rose: "border-rose-400/50 bg-rose-500/15 text-rose-200",
fuchsia: "border-fuchsia-400/50 bg-fuchsia-500/15 text-fuchsia-200",
};
/** A small status or tag chip. */
export function Pill({
tone = "default",
icon,
className = "",
children,
}: {
tone?: Tone;
icon?: IconName;
className?: string;
children: ReactNode;
}) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-display text-[11px] font-semibold tracking-wider whitespace-nowrap uppercase ${PILL_TONES[tone]} ${className}`}
>
{icon && <Icon name={icon} className="size-3" />}
{children}
</span>
);
}
const METER_FILLS: Partial<Record<Tone, string>> = {
amber: "from-amber-500 to-amber-300 shadow-[0_0_10px] shadow-amber-400/60",
cyan: "from-cyan-500 to-sky-300 shadow-[0_0_10px] shadow-cyan-400/60",
emerald: "from-emerald-500 to-emerald-300 shadow-[0_0_10px] shadow-emerald-400/60",
rose: "from-rose-600 to-rose-400 shadow-[0_0_10px] shadow-rose-500/60",
fuchsia: "from-fuchsia-500 to-fuchsia-300",
};
/** A labelled `progressbar`. `label` is its accessible name. */
export function Meter({
label,
value,
min = 0,
max = 100,
tone = "cyan",
size = "md",
className = "",
}: {
label: string;
value: number;
min?: number;
max?: number;
tone?: Tone;
size?: "sm" | "md";
className?: string;
}) {
const percent = max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
className={`overflow-hidden rounded-full bg-space-950/80 ring-1 ring-space-600/60 ${size === "sm" ? "h-1.5" : "h-2.5"} ${className}`}
>
<div
className={`h-full rounded-full bg-linear-to-r transition-[width] duration-700 ${METER_FILLS[tone] ?? METER_FILLS.cyan}`}
style={{ width: `${percent}%` }}
/>
</div>
);
}
const STAT_VALUE_TONES: Partial<Record<Tone, string>> = {
amber: "text-amber-300",
cyan: "text-cyan-200",
emerald: "text-emerald-300",
rose: "text-rose-300",
};
/** A label over a big number, with an optional icon. */
export function StatTile({
label,
value,
icon,
tone = "default",
valueLabel,
className = "",
}: {
label: ReactNode;
value: ReactNode;
icon?: IconName;
tone?: Tone;
/** Accessible name of the value, when tests or screen readers need it. */
valueLabel?: string;
className?: string;
}) {
return (
<div
className={`rounded-xl border border-space-600/70 bg-space-900/70 px-3 py-2.5 shadow-panel backdrop-blur-md ${className}`}
>
<div className="flex items-center gap-1.5 font-display text-[11px] font-semibold tracking-[0.16em] text-ink-400 uppercase">
{icon && <Icon name={icon} className="size-3.5" />}
{label}
</div>
<div
aria-label={valueLabel}
className={`mt-1 font-display text-lg font-bold tabular-nums ${STAT_VALUE_TONES[tone] ?? "text-ink-50"}`}
>
{value}
</div>
</div>
);
}
/** The page title block. `title` is the screen's level-1 heading. */
export function ScreenHeader({
title,
eyebrow,
subtitle,
icon,
actions,
}: {
title: string;
eyebrow?: string;
subtitle?: ReactNode;
icon?: IconName;
actions?: ReactNode;
}) {
return (
<div className="flex items-end justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
{icon && (
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-amber-400/40 bg-amber-500/10 text-amber-300 shadow-glow-amber">
<Icon name={icon} className="size-6" />
</span>
)}
<div className="min-w-0">
{eyebrow && (
<div className="font-display text-[11px] font-semibold tracking-[0.22em] text-cyan-300/80 uppercase">
{eyebrow}
</div>
)}
<h1 className="font-display text-2xl leading-tight font-bold tracking-wide uppercase">
{title}
</h1>
{subtitle && <p className="text-sm text-ink-300">{subtitle}</p>}
</div>
</div>
{actions}
</div>
);
}
/** A generated map sprite's URL (decision 0.5.50). */
export function spriteUrl(sprite: string, density: "1x" | "2x" = "2x"): string {
return `${ART_BASE_URL}/${sprite}@${density}.png`;
}
/**
* Ship or location art. Decorative: the name is always in text beside it, so
* `alt` is empty. A missing image hides itself (decision 0.5.50).
*/
export function Art({ sprite, className = "size-12" }: { sprite: string; className?: string }) {
const [failed, setFailed] = useState(false);
if (failed) return null;
return (
<img
src={spriteUrl(sprite, "1x")}
srcSet={`${spriteUrl(sprite, "1x")} 1x, ${spriteUrl(sprite, "2x")} 2x`}
alt=""
draggable={false}
loading="lazy"
onError={() => setFailed(true)}
className={`object-contain select-none ${className}`}
/>
);
}

135
apps/web/src/ui/icons.tsx Normal file
View File

@ -0,0 +1,135 @@
import type { ReactNode } from "react";
/** Line icons on a 24 px grid, drawn in `currentColor` (CR-005). */
const PATHS = {
hq: (
<>
<path d="M3 21h18M5 21V9l7-5 7 5v12" />
<path d="M9.5 21v-5h5v5M9 11.5h.01M15 11.5h.01" />
</>
),
map: (
<>
<circle cx="12" cy="12" r="3" />
<ellipse cx="12" cy="12" rx="10" ry="4.5" transform="rotate(-25 12 12)" />
<circle cx="20" cy="7.5" r="1" fill="currentColor" />
</>
),
jobs: (
<>
<rect x="5" y="4" width="14" height="17" rx="2" />
<path d="M9 4V2.5h6V4M8.5 10h7M8.5 14h7M8.5 18h4" />
</>
),
fleet: (
<>
<path d="M12 2.5c3 2 5 6 5 10l-1.6 3.5H8.6L7 12.5c0-4 2-8 5-10z" />
<circle cx="12" cy="10" r="1.8" />
<path d="M8.6 16 6 18.5V14l1.3-2.5M15.4 16l2.6 2.5V14l-1.3-2.5M10.5 19l1.5 2.5 1.5-2.5" />
</>
),
pilots: (
<>
<path d="M6 11a6 6 0 0 1 12 0v2H6v-2z" />
<path d="M8 13v1.5a4 4 0 0 0 8 0V13M4 21c1.2-2.6 4.2-4 8-4s6.8 1.4 8 4" />
</>
),
market: (
<>
<path d="M5 8h14l-1.2 13H6.2L5 8z" />
<path d="M9 10V6.5a3 3 0 0 1 6 0V10" />
</>
),
places: (
<>
<circle cx="12" cy="12" r="6" />
<path d="M5.2 15.6c-2.3 2-3 3.6-2.2 4.3 1.3 1.1 6.4-1.1 11-5s7.6-8.2 6.4-9.4c-.7-.7-2.4-.3-4.6 1" />
</>
),
credits: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M14.8 9.4A3 3 0 0 0 12 7.8c-2 0-3.3 1.6-3.3 4.2s1.3 4.2 3.3 4.2a3 3 0 0 0 2.8-1.6" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>
),
fuel: <path d="M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11z" />,
cargo: (
<>
<path d="M3 7.5 12 3l9 4.5v9L12 21l-9-4.5v-9z" />
<path d="M3 7.5 12 12l9-4.5M12 12v9" />
</>
),
arrow: <path d="M4 12h15M14 6l6 6-6 6" />,
lock: (
<>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7.5a4 4 0 0 1 8 0V11" />
</>
),
check: <path d="M5 12.5 9.5 17 19 7" />,
bolt: <path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z" />,
rest: <path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z" />,
alert: (
<>
<path d="M12 3 22 21H2L12 3z" />
<path d="M12 10v5M12 18v.01" />
</>
),
close: <path d="M6 6l12 12M18 6 6 18" />,
plus: <path d="M12 5v14M5 12h14" />,
target: (
<>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</>
),
send: <path d="M3 11 21 3l-8 18-2-8-8-2z" />,
hire: (
<>
<circle cx="10" cy="8" r="4" />
<path d="M2.5 21c1-3.8 3.9-6 7.5-6 1.8 0 3.4.5 4.7 1.4M19 14v6M16 17h6" />
</>
),
route: (
<>
<circle cx="5" cy="18" r="2.5" />
<circle cx="19" cy="6" r="2.5" />
<path d="M7 16.5C12 14 9 9 17 7.5" strokeDasharray="2 2.5" />
</>
),
speed: <path d="M3 17a9 9 0 0 1 18 0M12 17l4-6M7 13.5h.01M12 10h.01M17 13.5h.01" />,
range: (
<>
<circle cx="12" cy="12" r="9" strokeDasharray="2.5 2.5" />
<circle cx="12" cy="12" r="2" />
</>
),
landing: <path d="M12 3v11M7.5 9.5 12 14l4.5-4.5M4 20h16M6 17h12" />,
star: <path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9L12 3z" />,
} satisfies Record<string, ReactNode>;
export type IconName = keyof typeof PATHS;
export function Icon({ name, className = "size-5" }: { name: IconName; className?: string }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{PATHS[name]}
</svg>
);
}

7
apps/web/src/ui/index.ts Normal file
View File

@ -0,0 +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";

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

@ -5,11 +5,12 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatClock } from "../src/format";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const period = boardPeriodMs(content.config);
// 17.5 minutes into a board period.
const NOW = Math.ceil(Date.parse("2100-01-01T00:00:00Z") / period) * period + 17.5 * 60_000;
// A quarter of the way into a board period (derived from config, whatever its length).
const NOW = Math.ceil(Date.parse("2100-01-01T00:00:00Z") / period) * period + period / 4;
const periodEndsAt = (boardPeriodIndex(NOW, content.config) + 1) * period;
const courier: Ship = {
@ -79,7 +80,7 @@ describe("board refresh and the nothing-here case (Step 10D.8)", () => {
it("counts down to the next board period", async () => {
renderApp(api([heavyJob]), "/contracts?location=earth");
expect(await screen.findByLabelText("Board refresh")).toHaveTextContent(
"New contracts in 42:30",
`New contracts in ${formatClock(periodEndsAt - NOW)}`,
);
});

View File

@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../src/index.css"), "utf8");
describe("theme (CR-005)", () => {
it("self-hosts the display and body fonts", () => {
expect(css).toMatch(/@import "@fontsource\/chakra-petch\//);
expect(css).toMatch(/@import "@fontsource\/barlow\//);
expect(css).toMatch(/--font-display: "Chakra Petch"/);
expect(css).toMatch(/--font-sans: "Barlow"/);
});
it("puts every screen on the space backdrop", () => {
expect(css).toContain('url("/assets/map-gen/bg.webp")');
});
it("turns motion off under prefers-reduced-motion", () => {
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
});
});

View File

@ -0,0 +1,56 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Art, Button, Meter, ScreenHeader, Sheet, spriteUrl } from "../src/ui";
describe("UI kit (Step 10E.2)", () => {
it("buttons default to type=button and forward disabled and clicks", async () => {
const onClick = vi.fn();
const { rerender } = render(<Button onClick={onClick}>Go</Button>);
const button = screen.getByRole("button", { name: "Go" });
expect(button).toHaveAttribute("type", "button");
await userEvent.click(button);
expect(onClick).toHaveBeenCalledOnce();
rerender(
<Button onClick={onClick} disabled>
Go
</Button>,
);
expect(screen.getByRole("button", { name: "Go" })).toBeDisabled();
});
it("meters expose their range and value", () => {
render(<Meter label="Duty" value={9} max={14} />);
const bar = screen.getByRole("progressbar", { name: "Duty" });
expect(bar).toHaveAttribute("aria-valuemin", "0");
expect(bar).toHaveAttribute("aria-valuemax", "14");
expect(bar).toHaveAttribute("aria-valuenow", "9");
});
it("sheets are modal dialogs labelled by their title", () => {
render(
<Sheet titleId="t" title="Deliver to which HQ?">
<p>Body</p>
</Sheet>,
);
const dialog = screen.getByRole("dialog", { name: "Deliver to which HQ?" });
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(screen.getByRole("heading", { level: 2, name: "Deliver to which HQ?" })).toBeVisible();
});
it("the screen header is the level-1 heading", () => {
render(<ScreenHeader title="Fleet" eyebrow="Your ships" />);
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
});
it("art loads the generated sprite and hides itself if it fails", () => {
const { container } = render(<Art sprite="ship-courier" />);
const img = container.querySelector("img")!;
expect(img).toHaveAttribute("src", spriteUrl("ship-courier", "1x"));
expect(img).toHaveAttribute("alt", "");
expect(img.getAttribute("srcset")).toContain("/assets/map-gen/ship-courier@2x.png 2x");
fireEvent.error(img);
expect(container.querySelector("img")).toBeNull();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

View File

@ -0,0 +1,92 @@
# Change Request CR-005 — UI Overhaul
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10, CR-001, CR-002, CR-003 and CR-004 complete
**Do this:** as **Phase 10E**, after Phase 10D and before Phase 11
**Related docs:** `implementation-plan.md`, `CR-003-map-art.md`, `DECISIONS.md`
---
## 1. Why
Every screen except the map is plain slate cards with small text, and navigation is a row of seven small text links at the bottom of the screen. It works, but it doesn't look or feel like a game. The map art from CR-003 is the only place the game's look shows up.
This change gives the whole client one visual identity, a "freight terminal HUD": deep-space backgrounds, glowing amber and cyan accents, a techy display font for headings and numbers, big icon navigation, and the existing planet and ship art used on the screens, not just the map.
It is **presentation only**. No game rules, API calls, copy or accessible names change, so the existing client tests are the regression suite for this phase.
## 2. Visual direction
| Element | Direction |
|---|---|
| Background | The map's `bg.webp` behind every screen, dimmed under a navy gradient, so all screens are "in space". |
| Surfaces | Translucent navy panels with a thin cool border, a soft inner highlight and corner brackets on feature panels. |
| Accents | Amber = primary actions and the player's selection. Cyan = in flight and info. Emerald = money in and success. Rose = errors and blocks. Fuchsia = scripted story jobs. |
| Type | **Chakra Petch** (display: headings, numbers, tab labels, buttons, uppercase eyebrow labels) and **Barlow** (body). Self-hosted with `@fontsource`, so it works offline (Phase 11 PWA). |
| Numbers | Credits, payouts and countdowns use the display font with tabular figures; key numbers are large. |
| Navigation | Icon and label tabs. Phone: a bottom bar with 24 px icons and a glowing active indicator. Desktop (≥ 768 px): a left rail. Small badges call attention to things waiting for the player. |
| Art | The map's sprites (`public/assets/map-gen`): ship art on Fleet, Market and the dispatch sheet; planet art on Locations, the contract cards' route, the map panels and HQ. |
| Motion | Screens fade and rise in; buttons press down; claimable goals and ready badges pulse gently. All motion is off under `prefers-reduced-motion`. |
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, and `progress.md` is updated after every step.
- **No behavior changes.** Visible copy, roles and accessible names stay as they are, except where a step says otherwise. Existing tests must keep passing without edits to their assertions (a test may change only to follow a renamed or moved element that a step names).
- **Styling goes through the UI kit and theme tokens** (Step 10E.1–10E.2). Screens should not hard-code the old `slate-*` palette.
- No new art. Screens reuse the generated map sprites; the map's style config (`map/style.ts`) stays the map's own.
- Visual quality is a manual check: steps marked "manual check" end with desktop and phone screenshots in `docs/screenshots/`, named in `progress.md`. Stop for the user's review at the end of Phase 10E.
## 4. Decisions
Record these in `DECISIONS.md` (as plan §0.5.47–0.5.51).
- **0.5.47 Visual identity.** One "freight terminal HUD" look for the whole client (§2): space background, translucent panels, amber/cyan/emerald/rose/fuchsia accent roles, Chakra Petch + Barlow, self-hosted.
- **0.5.48 Tokens and kit.** Colors, fonts, radii, shadows and motion are Tailwind theme tokens in `apps/web/src/index.css`. Shared components (buttons, panels, sheets, stat tiles, meters, pills, screen headers, art) live in `apps/web/src/ui/`. Screens compose these.
- **0.5.49 Navigation.** Icon tabs, bottom bar on phones and a left rail from 768 px wide. Tab order and labels are unchanged. Attention badges are decorative (`aria-hidden`), so each tab's accessible name stays its label; the same information is on the screens.
- **0.5.50 Art on screens.** Ships and locations show their content `sprite` from the generated map art, as plain `<img>` with empty `alt` (the name is always in text next to it). A missing image hides itself, matching decision 0.5.41.
- **0.5.51 Presentation only.** The overhaul changes no game rules, API calls, copy or accessible names (§3).
## 5. Steps — Phase 10E
### Step 10E.1 — Fonts, theme tokens and backdrop
- Add `@fontsource/chakra-petch` and `@fontsource/barlow`. Define theme tokens in `index.css` (`@theme`): fonts, the panel/ink color scale, accent colors, glow shadows and keyframes (rise-in, pulse). Body uses Barlow; a global backdrop shows `bg.webp` under a dark gradient.
- Respect `prefers-reduced-motion` globally.
- **Test:** a test reads `index.css` and checks the font families, the backdrop image and the reduced-motion rule are present.
### Step 10E.2 — UI kit
- `src/ui/`: `Button` (primary, secondary, ghost, success, danger; sizes; pressed feedback), `Panel` (plain, feature with corner brackets, tone), `Sheet` (modal shell: backdrop, bottom sheet on phones, centered card from `sm`), `StatTile`, `Meter` (labelled `progressbar`), `Pill`, `ScreenHeader` (eyebrow, title as the screen's `h1`, subtitle), `Art` (sprite image), and the icon set used by navigation and screens.
- **Test:** `ui-kit.test.tsx`: buttons keep `type="button"` and forward `disabled`; `Meter` exposes progressbar values; `Sheet` is an `aria-modal` dialog labelled by its title; `ScreenHeader` renders a level-1 heading; `Art` resolves sprite URLs and hides on error.
### Step 10E.3 — App shell
- HUD header: logo mark and wordmark, credits in a glowing chip, game clock chip. The `Credits` and `Game clock` labels stay.
- Navigation per decision 0.5.49: bottom icon bar on phones, left rail from `md`. Badges: HQ = goals ready to claim; Jobs = idle crewed ships not resting; Pilots = pilots with unspent skill points. Computed by a pure `tabAttention(state)`.
- The map screen fills exactly the space left by the new header and navigation on both layouts.
- **Test:** existing `layout.test.tsx` passes; new tests for `tabAttention` and for a badge appearing without changing the tab's accessible name.
### Step 10E.4 — HQ dashboard
- Company hero (name, credits large), icon stat tiles, a "Next arrival" panel with a route strip and live progress, pilot rest, the tutorial card as a story panel, goals with reward pills, the loan with a meter.
- **Test:** existing HQ, goals, loan and rest tests pass. **Manual check.**
### Step 10E.5 — Contracts and dispatch
- Location switcher as planet chips. Contract cards: a route strip (origin art → line → destination art), cargo and mass, a large payout, requirement pills, expiry, and a prominent Dispatch button. The tutorial board gets the same card, with suggested and scripted variants. The dispatch sheet uses the `Sheet` and shows each ship with its art and the numbers as stat tiles.
- **Test:** existing contracts, tutorial board, dispatch and board refresh tests pass. **Manual check.**
### Step 10E.6 — Fleet and Market
- Ship cards with ship art, a status pill (idle, in flight), the route and a progress meter in flight, and actions as buttons. The deadhead picker uses `Sheet`. Market cards are "showroom" cards: large art, price, and stat meters for speed, capacity and range relative to the best model, plus landing class.
- **Test:** existing fleet and market tests pass. **Manual check.**
### Step 10E.7 — Pilots
- Pilot cards with an initials badge, level and XP meter, skills as pips out of their maximum with a raise button, the duty meter (amber, rose when near the limit) or rest status, and actions as buttons. The hire panel becomes a feature panel.
- **Test:** existing pilots and duty tests pass. **Manual check.**
### Step 10E.8 — Locations, map panels, start screen and dialogs
- Location cards with planet art, details as a compact spec list, the HQ status pill and the lease button. Undiscovered locations show dimmed art and a lock. The map's location and flight panels get the panel style with art. The start screen becomes a title screen over the space backdrop, with the sun and planets. The away summary and the deal use `Sheet`.
- **Test:** existing locations, map interaction, onboarding, away summary and deal tests pass. **Manual check.**
### Step 10E.9 — Regression and review
- Full suite, coverage, lint, typecheck and web build green. Screenshots of every screen at desktop and phone width (tutorial and mid-game). **Phase 10E complete — stop for review.**
## 6. Amendments to earlier steps
- **Step 8.1 (layout):** the header and bottom navigation are restyled per decision 0.5.49; desktop uses a left rail.
- **Step 11.1 (PWA):** the self-hosted font files are part of the offline asset cache.

View File

@ -102,6 +102,11 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
44. **Finish the job** (CR-004). Dispatch is blocked only while a pilot is resting. A trip that will exceed the limit is allowed; mandatory rest starts on arrival.
45. **Rest** (CR-004). Starting rest sets duty hours to zero and sets rest-ends-at to start time plus the rest duration. Mandatory rest starts at the trip's arrival time, so it is correct even if resolution happens later (offline).
46. **Rest covers a board refresh** (CR-004). The rest duration converted to real time must be at least the contract board period. This is enforced by a content test.
47. **Visual identity** (CR-005). One "freight terminal HUD" look for the whole client: space background, translucent panels, amber/cyan/emerald/rose/fuchsia accent roles, Chakra Petch + Barlow fonts, self-hosted.
48. **Tokens and kit** (CR-005). Colors, fonts, radii, shadows and motion are Tailwind theme tokens in `apps/web/src/index.css`; shared components live in `apps/web/src/ui/` and screens compose them.
49. **Navigation** (CR-005). Icon tabs: a bottom bar on phones, a left rail from 768 px wide. Tab order and labels are unchanged. Attention badges are decorative (`aria-hidden`), so each tab's accessible name stays its label.
50. **Art on screens** (CR-005). Ships and locations show their content `sprite` from the generated map art as plain images with empty `alt`; the name is always in text beside it. A missing image hides itself.
51. **Presentation only** (CR-005). The overhaul changes no game rules, API calls, copy or accessible names.
---
@ -403,6 +408,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 8.1 — Vite + React app
- Create the React app in `apps/web` with Tailwind, a basic layout (header with credits and game clock, main area, bottom navigation for mobile), and routing for: HQ, Contracts, Fleet, Pilots, Market, Locations.
- **Test:** A component test renders the layout and each route shows its placeholder title. Manual check: the layout fits a 375px-wide viewport with no horizontal scroll.
- **Amended by CR-005:** The header becomes a HUD bar and navigation becomes icon tabs: a bottom bar on phones, a left rail from 768 px, with attention badges (decision 0.5.49, Step 10E.3).
### Step 8.2 — Typed API client
- Build an API client from the route catalog so every call is typed and responses are validated.
@ -520,12 +526,19 @@ Steps 10D.1–10D.10 are specified in `memory-bank/change-requests/CR-004-pilot-
---
## Phase 10E — UI Overhaul (CR-005)
Steps 10E.1–10E.9 are specified in `memory-bank/change-requests/CR-005-ui-overhaul.md` §5, with its §6 amendments to earlier steps. Done after Phase 10D and before Phase 11; stop for review at the end.
---
## Phase 11 — PWA
### Step 11.1 — Installable PWA
- Add `vite-plugin-pwa` with a manifest (name "Deadhead: Space Freight Tycoon", icons, theme color, standalone display) and a service worker that caches the app shell.
- **Test:** A Lighthouse PWA audit in CI reports the app as installable. Manual check: the app installs to a phone home screen and launches standalone.
- **Amended by CR-003:** The service worker precaches the generated map assets in `public/assets/map-gen/` (not the source art in `public/assets/map/`, which the build omits).
- **Amended by CR-005:** The self-hosted font files (`@fontsource` Chakra Petch and Barlow, bundled into `dist/assets`) are part of the offline cache.
### Step 11.2 — Offline shell behavior
- When offline, the app loads from cache and shows the last known state with a clear "Offline — showing last update" banner. In-flight progress bars keep advancing from cached dispatch records. Actions are disabled until back online.

View File

@ -174,3 +174,17 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
- [x] **Step 10D.8 — Client: board refresh and the "nothing here" case.** Every location board shows "New contracts in mm:ss" to `periodEndsAt` and refetches itself just after it. For each idle, crewed, non-resting ship at the location with no contract it can take (by the sim, via `pairOptions`), a prompt offers "Rest [pilot] — wakes to a new board" (`restPilot`) and "Deadhead (fly empty)" (opens the Fleet's deadhead picker, now exported). Test: `board-refresh.test.tsx`.
- [x] **Step 10D.9 — Client: rest in summaries and on HQ.** The "While you were away" entry for a trip that ended in mandatory rest says "[Pilot] is resting until [local time]". HQ (after the tutorial) has a "Pilot rest" card: "Pilots resting: N" and the soonest to wake with a countdown, or "Everyone is ready to fly." Test: `rest-summary-hq.test.tsx`.
- [x] **Step 10D.10 — Plan amendments and regression.** CR-004 §6 amendments added to plan steps 2.1, 3.12, 3.17, 5.3, 9.5, 9.7, 12.1 and 12.6 (§0.5.42–46 were added with the CR). Full suite, coverage, lint and web build green. Scripted manual check against a live server: guest → 4 runs → deal → the avatar chains Earth↔Moon trips (2.1–2.5 game h each) and rests on arrival after the sixth (12 h limit); dispatch while resting is refused with `pilot-resting`; on waking, Earth's board is a new set. **Balance note for Phase 12:** each board has one guaranteed Courier-sized job per period, so after the first trip from a location in a period the Courier often has to deadhead or rest. **Phase 10D complete — stopped for review.**
## Phase 10E — UI Overhaul (CR-005)
Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorded as plan §0.5.47–0.5.51.
- [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.
- [x] **Step 10E.4 — HQ dashboard.** `ScreenHeader` with the company name; icon stat tiles (2 × 2 on phones, 4 across from `md`); two columns from `md`. New `components/RouteStrip` (origin art → dashed line → destination art; with `progress` the line fills and the ship's art rides along; `aria-hidden`, place names drawn with CSS `content` so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; `GoalsCard` rows with check discs, reward amounts and a glowing Claim; `LoanCard` with an emerald `Meter` and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. **Manual check:** scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9).
- [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9).
- [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
- [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
- [x] **Step 10E.8 — Locations, map panels, start screen and dialogs.** Locations ("Known space"): planet-art cards (two columns from `md`), emerald tone and an HQ pill where you have a bay, distance in cyan, specs on an inset `dl`, View contracts as a button, Lease bay as a full-width primary; undiscovered places dimmed and greyed with a padlock. Map: Reset view and the tap panels restyled (glass panel with the location's or ship's art, icon close button, View contracts as a button; a side card from `md`). Start screen is a title screen: gradient DEADHEAD wordmark, the Sun, Earth, Moon and a Courier around it, and a large Start button. "While you were away" and the deal are on `Sheet` ("Logbook" with big emerald credits and violet XP, discoveries with a star, goals panel with a success button; the deal as an amber "Incoming transmission" with the Courier's art and a large "Shake on it"). The last `slate-*` classes are gone from `apps/web/src`. Existing locations, map, onboarding, away and deal tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
- [x] **Step 10E.9 — Regression and review.** CR-005 §6 amendments added to plan steps 8.1 (the CR said 8.2; the layout is 8.1, corrected) and 11.1. Typecheck, lint, full suite with coverage (100 files, 680 tests) and the web build are green. The main JS chunk grew from 496 KB to 523 KB, past Vite's 500 KB warning; splitting it is left for Phase 11/13. **Review screenshots** (palette-quantized PNGs, 6 MB total): `docs/screenshots/10e9-final-{hq,map,contracts,dispatch,fleet,pilots,market,locations}-midgame-{desktop,phone}.png`, `10e9-final-{hq,contracts,map}-tutorial-{desktop,phone}.png` and `10e9-final-start-new-{desktop,phone}.png`. Phone shots are full-page captures, so the fixed bottom navigation appears partway down the image. **Manual check for the user:** the look on a real phone, and tap targets in the 7-tab bar on the narrowest phones. **Phase 10E complete — stopped for review.**

View File

@ -23,13 +23,13 @@ export const gameConfig: GameConfig = {
loanRepaymentShare: 0.3,
// Courier price + Earth bay lease.
dealLoanAmount: 5000,
contractBoardPeriodRealMinutes: 60,
contractBoardPeriodRealMinutes: 10,
// Duty and rest (CR-004): about three Moon round trips, then rest.
dutyLimitGameHours: 12,
longHaulDutyBonusGameHours: 2,
// One board period (60 real minutes at 60× compression), so a rested pilot
// One board period (10 real minutes at 60× compression), so a rested pilot
// always wakes to a fresh board (decision 0.5.46).
restGameHours: 60,
restGameHours: 10,
firstContactChance: 0.1,
firstContactPayoutMultiplier: 2,
// One band per ship size, so every board has work for every ship (playtest fix):

View File

@ -90,6 +90,12 @@ importers:
'@deadhead/sim':
specifier: workspace:*
version: link:../../packages/sim
'@fontsource/barlow':
specifier: ^5.3.0
version: 5.3.0
'@fontsource/chakra-petch':
specifier: ^5.3.0
version: 5.3.0
'@tanstack/react-query':
specifier: ^5.104.1
version: 5.104.1(react@19.3.0)
@ -861,6 +867,12 @@ packages:
'@fastify/proxy-addr@5.1.1':
resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==}
'@fontsource/barlow@5.3.0':
resolution: {integrity: sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==}
'@fontsource/chakra-petch@5.3.0':
resolution: {integrity: sha512-QZvbM/q2R6n1pg2x+TJPftZmBNS+ADU37L4Z4L/6UJIz+JZ/SRaEf8usPFRiP7Ht28sIideMu/Bm1SnGlSrwNw==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@ -3238,6 +3250,10 @@ snapshots:
'@fastify/forwarded': 3.0.2
ipaddr.js: 2.5.0
'@fontsource/barlow@5.3.0': {}
'@fontsource/chakra-petch@5.3.0': {}
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0