Compare commits

...

9 Commits

Author SHA1 Message Date
fireshaper 9564c5bd24 Step 29.9: regression, accessibility fixes and notes
CI / check (push) Failing after 22m33s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:55:57 -05:00
fireshaper 613b06a83d Step 29.8: Shipyard, Places, Start, Welcome and the remaining screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:41:02 -05:00
fireshaper 4a33aed970 Step 29.7: Dispatch, Fleet and Crew
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:33:47 -05:00
fireshaper d279ac4ec0 Step 29.6: sortable jobs table with net per hour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:28:06 -05:00
fireshaper 9aada41e8f Step 29.5: Bridge leads with your ship and Needs you
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:16:17 -05:00
fireshaper 38b636014f Step 29.4: four tabs, More and Fleet's views
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:10:40 -05:00
fireshaper 06bf2f17b1 Step 29.3: the map shell and panel host
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:05:00 -05:00
fireshaper 47ea4987b5 Step 29.2: UI kit in the Deep Field look
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:58:12 -05:00
fireshaper 9ce2ae36b7 Step 29.1: Deep Field theme tokens and type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:54:16 -05:00
105 changed files with 2204 additions and 1038 deletions

View File

@ -2086,3 +2086,28 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The countdown is the later of the pilot's rest and the ship's shop time**: rest overlapping service runs inside the shop time, so the shop's end is usually the later one.
- **The countdown uses the player's countdown setting** (real, game or both, CR-011), with the same mm:ss as the card's shop line.
- **Unchanged:** an uncrewed ship's Deadhead button (the deadhead sheet already explains "No pilot assigned" per destination), and Deadhead hidden while in the shop.
## CR-029 — Deep Field UI Rework (Phase 29)
- **Chosen from four prototypes:** the user picked the Deep Field direction with Switchyard's jobs table. Design reference: the Deep Field canvas linked from `memory-bank/change-requests/CR-029-deep-field-ui.md`. Decisions are plan §0.5.165 onward, recorded as each is built.
## Step 29.1
- **`--font-display` is Instrument Sans, and Michroma is a new `--font-title`.** `font-display` sits on labels and numbers throughout the screens, so pointing it at Michroma would make small text unreadable. `font-title` goes on the wordmark and screen titles in Step 29.2.
- **The default accent palettes (`amber-*`, `cyan-*` and so on) are not retuned.** Screens move to the new role tokens in Step 29.2, where each use can be judged; until then they keep their old colors.
- **`--color-ink-100` is new.** `text-ink-100` was used without a token, so that text silently inherited its color.
- **The faintest text got much lighter.** `ink-400` and `ink-500` were raised until they read at 4.5:1 on the glass over white, which narrows the gap between text levels. Hierarchy has to come from size and weight as well.
- **The theme color and icon background follow the ground** (`--color-space-950`, #050919), and the icons were regenerated with `pnpm icons`.
- **Screens outside the map are plain navy until Step 29.3,** when the map moves behind every screen.
## Steps 29.2–29.9
- **Accent classes stayed; their scales changed (differs from Step 29.2).** `amber-*`, `cyan-*`, `sky-*`, `emerald-*` and `rose-*` are retuned to the role hues in `index.css`, and screens were swept so amber marks actions and warnings. Rewriting all 350 uses to role tokens by hand added risk and no visible change. New code uses the role tokens.
- **Old URLs are kept, not redirected (differs from Step 29.4).** `/pilots`, `/hqs`, `/market`, `/locations` and `/map` remain real routes under their tabs, so links, tests and saved habits all keep working. `/map` is the map view, since a selection needs somewhere to show.
- **Fleet has no separate ship detail screen.** The canvas drew one; the cards were made compact instead, and Fuel and Upgrades stay as sheets. It keeps every existing flow and test.
- **`font-display` remains in the code as an alias of the body font.** Removing it from about 40 files would change nothing on screen.
- **Phones get a nearly opaque sheet and no blur.** Map labels showed through at 82%, and blur is the biggest drawing cost; the 82% glass and blur apply from 768 px.
- **The map isn't highlighted for a selected job.** The canvas showed the route; the renderer has no such layer yet, so it was left out of this rework.
- **Plain Standard cargo has no chip in the table,** and only real requirements are mentioned; "No skills needed" went away as noise. Payout, distance and expiry stay in the row on wide panels and in the dispatch sheet's job details on phones (payout) or via the board's refresh countdown (expiry).
- **Dispatch is a modal below 1280 px** and beside the table above it; there isn't room beside a wide panel below that.
- **Step 29.9 checks:** axe (WCAG 2.0, 2.1 and 2.2 A/AA) reports nothing on 17 screens and sheets, though it can't judge text over the map; a 22-ship fleet stays usable. Frame rate held 60 in headless desktop Chrome only: a real mid-range phone is still untested.

View File

@ -77,7 +77,7 @@ test.describe("end to end (Phase 12)", () => {
// The Lighter's pair can take it in the dispatch sheet.
await page.goto("/contracts?location=moon");
const card = page
.getByRole("listitem")
.getByRole("row")
.filter({ hasText: content.good(job!.goodId)!.name })
.filter({ hasText: `${job!.massTonnes} t` });
await card.getByRole("button", { name: "Dispatch" }).first().click();

View File

@ -15,7 +15,7 @@ test.describe("installable PWA (Step 11.1)", () => {
expect(manifest).toMatchObject({
name: "Deadhead: Space Freight Tycoon",
display: "standalone",
theme_color: "#080d1d",
theme_color: "#050919",
});
const sizes = manifest.icons.map((i: { sizes: string; purpose?: string }) => i.sizes);
expect(sizes).toEqual(expect.arrayContaining(["192x192", "512x512"]));
@ -43,8 +43,8 @@ test.describe("installable PWA (Step 11.1)", () => {
expect.arrayContaining([
expect.stringContaining("/index.html"),
expect.stringMatching(/\/assets\/map-gen\/body-earth@2x\.png/),
expect.stringMatching(/\/assets\/.*chakra-petch.*\.woff2/),
expect.stringMatching(/\/assets\/.*barlow.*\.woff2/),
expect.stringMatching(/\/assets\/.*michroma.*\.woff2/),
expect.stringMatching(/\/assets\/.*instrument-sans.*\.woff2/),
]),
);
const urls = await page.evaluate(async () => {

View File

@ -130,7 +130,7 @@ export async function playToDeal(page: Page) {
await page.goto("/");
await dismissLogbook(page);
await page.getByRole("button", { name: "Shake on it" }).click();
await expect(page.getByRole("heading", { level: 1, name: "Dashboard" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: "Bridge" })).toBeVisible();
// Both pilots have skill points at the deal (CR-017); the pop-up says so.
const skills = page.getByRole("dialog", { name: "Your pilots are ready to train" });
await expect(skills.getByLabel("Unspent skill points")).toContainText(/You: \d+ points?/);

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#080d1d" />
<meta name="theme-color" content="#050919" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>Deadhead: Space Freight Tycoon</title>

View File

@ -22,8 +22,8 @@
"@deadhead/engine": "workspace:*",
"@deadhead/schema": "workspace:*",
"@deadhead/sim": "workspace:*",
"@fontsource/barlow": "^5.3.0",
"@fontsource/chakra-petch": "^5.3.0",
"@fontsource/instrument-sans": "^5.3.0",
"@fontsource/michroma": "^5.3.0",
"@tanstack/react-query": "^5.104.1",
"pixi.js": "^8.22.0",
"react": "^19.3.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -9,8 +9,8 @@ import sharp from "sharp";
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const outDir = join(webRoot, "public/icons");
/** --color-space-900 in src/index.css: the theme and background color. */
export const BACKGROUND = "#080d1d";
/** --color-space-950 in src/index.css: the theme and background color. */
export const BACKGROUND = "#050919";
/**
* Each icon: size in px and how much of it the ship fills. Maskable icons keep

View File

@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, type ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { Route, Routes } from "react-router";
import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock";
@ -17,7 +17,9 @@ import { Fleet } from "./screens/Fleet";
import { Dashboard } from "./screens/Dashboard";
import { HQs } from "./screens/HQs";
import { Locations } from "./screens/Locations";
import { MapScreen } from "./screens/MapScreen";
import { Market } from "./screens/Market";
import { More } from "./screens/More";
import { Pilots } from "./screens/Pilots";
import { Logbook } from "./screens/Logbook";
import { Settings } from "./screens/Settings";
@ -27,22 +29,11 @@ import { usePlayerState, useRefetchOnArrival } from "./state/player";
import { RadioCallSheet, useRefetchOnRadio } from "./components/RadioCalls";
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 })));
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/": <Dashboard />,
"/map": (
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
<MapScreen />
</Suspense>
),
"/contracts": <Contracts />,
"/fleet": <Fleet />,
"/pilots": <Pilots />,
"/hqs": <HQs />,
"/market": <Market />,
"/locations": <Locations />,
"/more": <More />,
};
/** Reads the game clock (the engine's device clock, CR-014), then ticks it every second. */
@ -64,11 +55,7 @@ export function App() {
const retry = () => void state.refetch();
if (state.isPending) {
return (
<p className="grid min-h-dvh place-items-center font-display tracking-[0.3em] text-ink-400 uppercase">
Loading…
</p>
);
return <p className="grid min-h-dvh place-items-center font-display text-ink-400">Loading…</p>;
}
if (state.data === undefined) {
// The first load failed: nothing to show yet.
@ -116,7 +103,13 @@ export function App() {
element={SCREEN_COMPONENTS[screen.path]}
/>
))}
{/* Settings opens from the header gear, not a tab (CR-011). */}
{/* Screens without a tab of their own (decision 0.5.167). */}
<Route path="/map" element={<MapScreen />} />
<Route path="/pilots" element={<Pilots />} />
<Route path="/hqs" element={<HQs />} />
<Route path="/market" element={<Market />} />
<Route path="/locations" element={<Locations />} />
{/* Settings opens from the header gear and More, not a tab (CR-011). */}
<Route path="/settings" element={<Settings />} />
{/* The Logbook opens from "While you were away" and the Bridge (CR-025). */}
<Route path="/logbook" element={<Logbook />} />

View File

@ -84,11 +84,7 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
};
if (status === "starting") {
return (
<p className="grid min-h-dvh place-items-center font-display tracking-[0.3em] text-ink-400 uppercase">
Loading…
</p>
);
return <p className="grid min-h-dvh place-items-center font-display text-ink-400">Loading…</p>;
}
if (status === "elsewhere") {
return (

View File

@ -291,7 +291,7 @@ export function AwaySummary() {
<ul aria-label="What happened" className="space-y-1.5">
{summary.direct.map((entry) => (
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
<Icon name="send" className="mt-0.5 size-4 shrink-0 text-cyan-300" />
<Icon name="send" className="mt-0.5 size-4 shrink-0 text-ink-300" />
{directText(entry as LogEntry, state)}
</li>
))}
@ -302,7 +302,7 @@ export function AwaySummary() {
{highlights.length > 0 && (
<ul aria-label="Highlights" className="space-y-1.5">
{highlights.map((h, i) => (
<li key={i} className="flex items-start gap-2 text-amber-100">
<li key={i} className="flex items-start gap-2 text-ink-100">
<Icon
name={h.kind === "problem" ? "alert" : "star"}
className="mt-0.5 size-4 shrink-0 text-amber-300"
@ -346,19 +346,17 @@ function Totals({ summary }: { summary: Summary }) {
[words.totals.deliveries, String(deliveries), "text-ink-50"],
];
if (discoveries > 0)
figures.push([words.totals.discoveries, String(discoveries), "text-cyan-200"]);
figures.push([words.totals.discoveries, String(discoveries), "text-ink-100"]);
if (loanRepaid > 0)
figures.push([words.totals.loanRepaid, formatCredits(loanRepaid), "text-ink-50"]);
return (
<dl
aria-label="Company totals"
className="grid grid-flow-col gap-2 rounded-xl border border-cyan-400/30 bg-cyan-950/30 p-3"
className="grid grid-flow-col gap-2 rounded-xl border border-glass-line bg-white/5 p-3"
>
{figures.map(([label, value, tone]) => (
<div key={label}>
<dt className="font-display text-[10px] tracking-wider text-ink-400 uppercase">
{label}
</dt>
<dt className="font-display text-xs text-ink-400">{label}</dt>
<dd className={`font-display text-base font-bold tabular-nums ${tone}`}>{value}</dd>
</div>
))}
@ -382,7 +380,7 @@ function PilotLines({ summary }: { summary: Summary }) {
})}
</span>
{line.leveledUp && (
<span className="shrink-0 rounded-full bg-amber-500/20 px-1.5 text-[10px] font-semibold text-amber-200 uppercase">
<span className="shrink-0 rounded-full bg-amber-500/20 px-1.5 text-xs font-semibold text-ink-100">
{words.leveledUp}
</span>
)}

View File

@ -56,27 +56,23 @@ export function DealModal() {
of every job.
</p>
</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>
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-black/25 p-3 text-sm ring-1 ring-amber-400/25">
<dt className="font-display text-xs text-ink-400">You get</dt>
<dd aria-label="Ships and bay">
A {courier.name} for you, a {clipper.name} for {veteran.name}, and a bay at {bay.name}
</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Pilot</dt>
<dt className="font-display text-xs text-ink-400">Pilot</dt>
<dd aria-label="Veteran pilot">
…and {veteran.name}. {veteran.bio}
</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Loan</dt>
<dt className="font-display text-xs text-ink-400">Loan</dt>
<dd
aria-label="Loan amount"
className="font-display font-semibold text-amber-200 tabular-nums"
className="font-display font-semibold text-ink-100 tabular-nums"
>
{formatCredits(dealLoanAmount)}
</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Repayment
</dt>
<dt className="font-display text-xs text-ink-400">Repayment</dt>
<dd aria-label="Repayment share">
{Math.round(loanRepaymentShare * 100)}% of every payout until it&apos;s paid off
</dd>

View File

@ -47,7 +47,7 @@ export function SleepBanner() {
const { sleepsAt, afterHours } = state.fleetSleep;
return (
<Panel tone="amber" className="text-sm" aria-label="Fleet asleep">
<p className="flex items-start gap-2 text-amber-100">
<p className="flex items-start gap-2 text-ink-100">
<Icon name="rest" className="mt-0.5 size-4 shrink-0" />
Your fleet went quiet on {formatSleepTime(sleepsAt)} after {sleepLabel(afterHours)} without
orders.
@ -63,7 +63,7 @@ export function FleetAsleep() {
if (!fleetQuiet(state)) return null;
return (
<Panel tone="amber" className="text-sm" aria-label="Fleet asleep">
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-amber-100">
<p className="flex items-center gap-2 font-display font-semibold text-ink-100">
<Icon name="rest" className="size-4 shrink-0" />
Fleet asleep — waiting for your orders
</p>

View File

@ -0,0 +1,53 @@
import { NavLink } from "react-router";
import { useLoadedPlayer } from "../state/actions";
/** Fleet's two views, Ships and Crew (decision 0.5.167). */
export function FleetViews() {
const state = useLoadedPlayer();
return (
<Views
label="Fleet views"
views={[
{ to: "/fleet", label: `Ships ${state.ships.length}` },
{ to: "/pilots", label: `Crew ${state.pilots.length}` },
]}
/>
);
}
/** Places and HQs, under More (decision 0.5.167). */
export function PlacesViews() {
const state = useLoadedPlayer();
return (
<Views
label="Places views"
views={[
{ to: "/locations", label: "Places" },
{ to: "/hqs", label: `HQs ${state.bays.length}` },
]}
/>
);
}
function Views({ label, views }: { label: string; views: { to: string; label: string }[] }) {
return (
<nav
aria-label={label}
className="flex gap-0.5 rounded-xl border border-glass-line bg-black/25 p-0.5"
>
{views.map((view) => (
<NavLink
key={view.to}
to={view.to}
className={({ isActive }) =>
`flex min-h-11 flex-1 items-center justify-center rounded-[0.6rem] text-sm font-medium ${
isActive ? "bg-white/14 text-ink-50" : "text-ink-300 hover:text-ink-50"
}`
}
>
{view.label}
</NavLink>
))}
</nav>
);
}

View File

@ -54,7 +54,7 @@ export function FuelGauge({ ship }: { ship: Ship }) {
label={`${ship.name} fuel`}
value={ship.fuelFu}
max={capacity}
tone={percent < 25 ? "rose" : "amber"}
tone={percent < 25 ? "rose" : "default"}
size="sm"
/>
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
@ -183,7 +183,7 @@ export function ShipFuel({ shipId, onClose }: { shipId: string; onClose: () => v
placeholder="Amount (fu)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-ink-50 focus:border-amber-400 focus:outline-none"
className="w-full rounded-lg border border-glass-line bg-black/25 px-3 py-2 text-ink-50 focus:border-action focus:outline-none"
/>
</label>
<Button
@ -202,11 +202,11 @@ export function ShipFuel({ shipId, onClose }: { shipId: string; onClose: () => v
<fieldset className="mt-4 text-sm">
<legend className="text-ink-400">Refuel policy</legend>
<div className="mt-1 flex overflow-hidden rounded-lg border border-space-600">
<div className="mt-1 flex overflow-hidden rounded-lg border border-glass-line">
{(["manual", "auto"] as const).map((value) => (
<label
key={value}
className="flex-1 cursor-pointer px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
className="flex-1 cursor-pointer px-3 py-1.5 text-center has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
>
<input
type="radio"

View File

@ -14,9 +14,9 @@ export function GoalsCard() {
if (next.length === 0) return null;
return (
<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" />
<Panel as="section" id="goals" aria-labelledby="goals-title">
<h2 id="goals-title" className="flex items-center gap-2 text-sm font-semibold">
<Icon name="target" className="size-4 text-ink-300" />
Goals
</h2>
<ul className="mt-3 space-y-2">
@ -28,23 +28,21 @@ export function GoalsCard() {
<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"
ready ? "border-earned/45 bg-earned/8" : "border-glass-line"
}`}
>
<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 ? "bg-earned text-space-950 ring-earned" : "ring-ink-400"
}`}
>
{ready && <Icon name="check" className="size-3.5" />}
</span>
<span className={ready ? "text-emerald-200" : "text-ink-200"}>
<span className={"text-ink-100"}>
{details.title}
<span className="ml-2 font-display text-xs font-semibold whitespace-nowrap text-amber-300/90">
<span className="ml-2 text-xs font-semibold whitespace-nowrap text-earned">
+{formatCredits(details.reward)}
</span>
</span>
@ -55,7 +53,7 @@ export function GoalsCard() {
size="sm"
disabled={claim.isPending}
onClick={() => claim.mutate({ goalId: goal.id })}
className="shrink-0 animate-glow"
className="shrink-0"
>
Claim
</Button>

View File

@ -10,7 +10,7 @@ import { goodName } from "./Goods";
import { serviceStatus } from "./Service";
const inputClass =
"rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none";
"rounded-lg border border-glass-line bg-black/25 px-3 py-2 text-sm text-ink-50 focus:border-action focus:outline-none";
/** The one-time Dispatch Office on the Bridge (decision 0.5.123). */
export function DispatchOfficePanel() {
@ -22,14 +22,14 @@ export function DispatchOfficePanel() {
if (state.dispatchOffice.owned) {
return (
<Panel tone="cyan" className="text-sm" aria-label="Dispatch Office">
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-cyan-100">
<p className="flex items-center gap-2 font-display font-semibold text-ink-100">
<Icon name="send" className="size-4" />
Dispatch Office
</p>
<p className="mt-1 text-ink-300">
Your hired pilots can run jobs on their own. Set them to Independent on{" "}
<Link to="/pilots" className="font-semibold text-amber-300 underline">
Pilots
Your hired pilots can run jobs on their own. Set them to Independent in{" "}
<Link to="/pilots" className="font-semibold underline">
Crew
</Link>
.
</p>
@ -37,13 +37,15 @@ export function DispatchOfficePanel() {
);
}
return (
<Panel tone="amber" feature className="text-sm" aria-label="Dispatch Office">
<p className="font-display font-semibold tracking-wide text-amber-100">Dispatch Office</p>
<Panel className="text-sm" aria-label="Dispatch Office">
<p className="font-semibold">Dispatch Office</p>
<p className="mt-1 text-ink-300">
Lets your hired pilots run jobs on their own, so the company keeps earning while you&apos;re
away.
</p>
<Button
variant="secondary"
size="sm"
className="mt-3"
disabled={state.company.credits < price || buy.isPending}
onClick={() => buy.mutate({})}
@ -98,16 +100,14 @@ export function PilotIndependence({ pilot }: { pilot: Pilot }) {
const status = independentStatus(state, pilot);
return (
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
<div className="mt-3 rounded-lg bg-black/25 p-2.5 ring-1 ring-glass-line">
<fieldset>
<legend className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Flies
</legend>
<div className="mt-1 flex overflow-hidden rounded-lg border border-space-600">
<legend className="font-display text-xs text-ink-400">Flies</legend>
<div className="mt-1 flex overflow-hidden rounded-lg border border-glass-line">
{(["manual", "independent"] as const).map((mode) => (
<label
key={mode}
className={`flex-1 px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2 ${owned ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}
className={`flex-1 px-3 py-1.5 text-center has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2 ${owned ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}
>
<input
type="radio"
@ -129,7 +129,7 @@ export function PilotIndependence({ pilot }: { pilot: Pilot }) {
)}
</fieldset>
{status && (
<p aria-label={`${pilot.name} status`} className="mt-2 text-cyan-100">
<p aria-label={`${pilot.name} status`} className="mt-2 text-ink-100">
{status}
</p>
)}
@ -155,9 +155,7 @@ function OrdersEditor({ pilot }: { pilot: Pilot }) {
return (
<details className="mt-2">
<summary className="cursor-pointer font-display text-xs tracking-wider text-amber-200 uppercase">
Orders
</summary>
<summary className="cursor-pointer font-display text-xs text-ink-100">Orders</summary>
<form
aria-label={`Orders for ${pilot.name}`}
className="mt-2 space-y-3 text-sm"

View File

@ -26,11 +26,8 @@ export function LoanCard() {
return (
<Panel>
<div className="flex items-baseline justify-between gap-2">
<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"
>
<h2 className="text-sm font-semibold">Loan from Halvorsen</h2>
<span aria-label="Loan balance" className="font-semibold tabular-nums">
{formatCredits(loanBalance)} left
</span>
</div>
@ -39,9 +36,7 @@ export function LoanCard() {
until it&apos;s 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>
<p className="mt-1 text-right text-xs text-ink-300">{paidOff}% paid off</p>
<form
className="mt-2 flex items-center gap-2"
@ -59,9 +54,13 @@ export function LoanCard() {
placeholder="Amount"
value={input}
onChange={(e) => setInput(e.target.value)}
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"
className="min-h-11 min-w-0 flex-1 rounded-xl border border-glass-line bg-black/25 px-3 tabular-nums placeholder:text-ink-400 focus:border-action focus:outline-none"
/>
<Button type="submit" disabled={input === "" || problem !== null || repay.isPending}>
<Button
type="submit"
variant="secondary"
disabled={input === "" || problem !== null || repay.isPending}
>
Pay down
</Button>
</form>

View File

@ -0,0 +1,198 @@
import { content } from "@deadhead/content";
import type { PlayerState } from "@deadhead/schema";
import { Link } from "react-router";
import { formatClock, formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions";
import { Icon } from "../ui";
import { readyShips } from "../game";
import type { ScreenPath } from "../routes";
import { Countdown } from "./Countdown";
import { useRadioLater } from "./RadioCalls";
/** One thing waiting on the player, and the tab whose badge counts it (decision 0.5.168). */
export type NeedsYouItem =
| {
kind: "radio";
tab: ScreenPath;
count: 1;
dispatchId: string;
eventId: string;
pilotId: string;
endsAt: number;
}
| { kind: "ready"; tab: ScreenPath; count: 1; name: string; locationId: string | null }
| { kind: "skills"; tab: ScreenPath; count: number }
| { kind: "goal"; tab: ScreenPath; count: 1; goalId: string };
/** Everything waiting on the player, most urgent first. */
export function needsYou(state: PlayerState): NeedsYouItem[] {
const items: NeedsYouItem[] = state.radioCalls.map((call) => ({
kind: "radio",
tab: "/",
count: 1,
dispatchId: call.dispatchId,
eventId: call.eventId,
pilotId: call.pilotId,
endsAt: call.windowEndsAt,
}));
if (state.tutorial) {
// The company loaner isn't in the ships list.
if (!state.tutorial.runInFlight) {
items.push({
kind: "ready",
tab: "/contracts",
count: 1,
name: "Company loaner",
locationId: state.tutorial.loanerLocationId,
});
}
} else {
for (const ship of readyShips(state)) {
items.push({
kind: "ready",
tab: "/contracts",
count: 1,
name: ship.name,
locationId: ship.locationId,
});
}
}
const points = state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0);
if (points > 0) items.push({ kind: "skills", tab: "/fleet", count: points });
for (const goal of state.goals.filter((g) => g.status === "ready")) {
items.push({ kind: "goal", tab: "/", count: 1, goalId: goal.id });
}
return items;
}
const ROW = "flex min-h-14 w-full items-center gap-3 py-2 text-left";
function Dot({ tone }: { tone: "problem" | "action" | "earned" | "ink" }) {
const fill = {
problem: "bg-problem",
action: "bg-action",
earned: "bg-earned",
ink: "bg-ink-300",
}[tone];
return <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${fill}`} />;
}
function RowText({ title, detail }: { title: string; detail: React.ReactNode }) {
return (
<span className="min-w-0 flex-1">
<span className="block leading-snug font-medium">{title}</span>
<span className="block text-[13px] leading-snug text-ink-300">{detail}</span>
</span>
);
}
const Go = () => <Icon name="arrow" className="size-4 shrink-0 text-ink-400" />;
/** The Bridge's list of what needs the player (decision 0.5.168). */
export function NeedsYou() {
const state = useLoadedPlayer();
const open = useRadioLater((s) => s.open);
const items = needsYou(state);
const radio = items.filter((i) => i.kind === "radio");
const rest = items.filter((i) => i.kind !== "radio");
const pilot = (id: string) => state.pilots.find((p) => p.id === id)?.name ?? "Your pilot";
return (
<section aria-labelledby="needs-you-title">
<h2 id="needs-you-title" className="text-sm font-semibold">
Needs you
</h2>
{items.length === 0 && (
<p className="py-2 text-sm text-ink-300">Nothing right now. Your fleet has it in hand.</p>
)}
{radio.length > 0 && (
<ul aria-label="Radio calls" className="divide-y divide-glass-line">
{radio.map((call) => (
<li key={call.dispatchId} className={ROW}>
<Dot tone="problem" />
<RowText
title={content.tripEvent(call.eventId)?.title ?? "Radio call"}
detail={
<>
{pilot(call.pilotId)} ·{" "}
<Countdown to={call.endsAt} format={formatClock} done="now" />
</>
}
/>
<button
type="button"
onClick={() => open(call.dispatchId)}
className="min-h-11 shrink-0 rounded-xl bg-action px-3 text-sm font-semibold text-on-action"
>
Answer
</button>
</li>
))}
</ul>
)}
{rest.length > 0 && (
<ul aria-label="Other things waiting" className="divide-y divide-glass-line">
{rest.map((item) => {
if (item.kind === "ready") {
const to = state.tutorial
? "/contracts"
: `/contracts?location=${item.locationId ?? ""}`;
return (
<li key={`ready-${item.name}`}>
<Link to={to} className={ROW}>
<Dot tone="action" />
<RowText
title={`${item.name} is ready`}
detail={`At ${locationName(item.locationId ?? "")} · find it a job`}
/>
<Go />
</Link>
</li>
);
}
if (item.kind === "skills") {
const who = state.pilots
.filter((p) => p.skillPointsAvailable > 0)
.map((p) => `${p.isAvatar ? "You" : p.name} ${p.skillPointsAvailable}`)
.join(" · ");
return (
<li key="skills">
<Link to="/pilots" className={ROW}>
<Dot tone="ink" />
<RowText
title={`${item.count} skill ${item.count === 1 ? "point" : "points"} to spend`}
detail={who}
/>
<Go />
</Link>
</li>
);
}
if (item.kind === "goal") {
const goal = content.onboardingGoal(item.goalId);
return (
<li key={item.goalId}>
<button
type="button"
onClick={() =>
document.getElementById("goals")?.scrollIntoView?.({ behavior: "smooth" })
}
className={ROW}
>
<Dot tone="earned" />
<RowText
title={`Goal ready: +${formatCredits(goal?.reward ?? 0)}`}
detail={goal?.title ?? ""}
/>
<Go />
</button>
</li>
);
}
return null;
})}
</ul>
)}
</section>
);
}

View File

@ -100,14 +100,12 @@ function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
{locationName(from)} → {locationName(to)}
</span>
);
const fuel = (cost: number) => (
<span className="text-amber-200/90">fuel {formatCredits(cost)}</span>
);
const fuel = (cost: number) => <span className="text-ink-100">fuel {formatCredits(cost)}</span>;
switch (entry.kind) {
case "flight":
return entry.deadhead ? (
<>
<Icon name="send" className="size-4 text-cyan-300" />
<Icon name="send" className="size-4 text-ink-300" />
<span>Deadhead</span>
{route(entry.originId, entry.destinationId)}
{fuel(entry.fuelCost)}
@ -126,61 +124,61 @@ function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
{entry.loanRepayment > 0 && (
<span className="text-ink-400">lender {formatCredits(entry.loanRepayment)}</span>
)}
{entry.xp > 0 && <span className="text-violet-300">+{entry.xp} XP</span>}
{entry.xp > 0 && <span className="text-ink-300">+{entry.xp} XP</span>}
</>
);
case "refuel":
return (
<span className="text-amber-200/90">
<span className="text-ink-100">
{Math.round(entry.boughtFu)} fu at {locationName(entry.locationId)},{" "}
{formatCredits(entry.cost)} ({REFUEL_SOURCE[entry.source]})
</span>
);
case "rest":
return (
<span className="flex items-center gap-1 text-sky-300">
<span className="flex items-center gap-1 text-ink-300">
<Icon name="rest" className="size-4" />
Rested until {formatLocalTime(entry.until)}
</span>
);
case "upgrade":
return (
<span className="text-cyan-100">
<span className="text-ink-100">
{shipName(state, entry.shipId)}:{" "}
{content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId} level {entry.level} finished
</span>
);
case "discovery":
return <span className="text-cyan-200">Discovered {locationName(entry.locationId)}</span>;
return <span className="text-ink-100">Discovered {locationName(entry.locationId)}</span>;
case "level-up":
return (
<span className="text-amber-200">
<span className="text-ink-100">
Reached level {entry.level} · {entry.skillPoints}{" "}
{entry.skillPoints === 1 ? "skill point" : "skill points"}
</span>
);
case "event":
return (
<span className={entry.good ? "text-emerald-200" : "text-amber-200"}>
<span className={entry.good ? "text-emerald-200" : "text-ink-100"}>
{eventResultText(entry, state)}
</span>
);
case "repair":
return (
<span className="text-cyan-100">
<span className="text-ink-100">
{shipName(state, entry.shipId)}: hull repaired from {entry.fromPct}% to 100%,{" "}
{formatCredits(entry.cost)}
</span>
);
case "service":
return (
<span className={entry.phase === "waiting-funds" ? "text-amber-200" : "text-cyan-100"}>
<span className={entry.phase === "waiting-funds" ? "text-ink-100" : "text-ink-100"}>
{serviceEntryText(entry, shipName(state, entry.shipId))}
</span>
);
case "goal-ready":
return (
<span className="text-amber-200">
<span className="text-ink-100">
Goal ready: {content.onboardingGoal(entry.goalId)?.title ?? entry.goalId}
</span>
);
@ -201,13 +199,13 @@ function PilotSection({ log, state, open }: { log: PilotLog; state: PlayerState;
const [shown, setShown] = useState(PAGE);
const now = useClock((c) => c.now);
return (
<details open={open} className="rounded-xl border border-space-600 bg-space-950/50 p-3">
<summary className="flex cursor-pointer items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">{log.name}</span>
{log.xp > 0 && <span className="text-xs text-violet-300">+{log.xp} XP</span>}
<details open={open} className="rounded-xl border border-glass-line bg-black/25 p-3">
<summary className="flex min-h-11 cursor-pointer items-center justify-between gap-2">
<span className="font-display font-bold">{log.name}</span>
{log.xp > 0 && <span className="text-xs text-ink-300">+{log.xp} XP</span>}
</summary>
{log.now && (
<p aria-label={`${log.name} now`} className="mt-1 text-cyan-100">
<p aria-label={`${log.name} now`} className="mt-1 text-ink-100">
{log.now}
</p>
)}
@ -240,18 +238,18 @@ export function PilotLogbook({ logbook, state }: { logbook: Logbook; state: Play
<section aria-label="Pilot logbook" className="space-y-3 text-sm">
<div
aria-label="Logbook totals"
className="grid grid-cols-2 gap-x-3 gap-y-1 rounded-xl border border-cyan-400/40 bg-cyan-950/30 p-3 sm:grid-cols-4"
className="grid grid-cols-2 gap-x-3 gap-y-1 rounded-xl border border-glass-line bg-white/5 p-3 sm:grid-cols-4"
>
<span>{totals.jobs} jobs</span>
<span>{totals.deadheads} deadheads</span>
<span className="text-emerald-300">{formatCredits(totals.payout)} paid</span>
<span className="text-ink-300">{formatCredits(totals.pilotShare)} pilot shares</span>
<span className="text-amber-200">{formatCredits(totals.fuelCost)} fuel</span>
<span className="text-ink-100">{formatCredits(totals.fuelCost)} fuel</span>
<span className="text-ink-300">{formatCredits(totals.loanRepaid)} to the lender</span>
<span className={totals.net < 0 ? "text-rose-300" : "text-emerald-300"}>
{formatCredits(totals.net)} net
</span>
<span className="text-violet-300">+{totals.xp} XP</span>
<span className="text-ink-300">+{totals.xp} XP</span>
</div>
{logbook.pilots.length === 0 ? (
<p className="text-ink-400">Nothing logged in this time.</p>
@ -287,7 +285,7 @@ export function CatchingUp({ busy, delayMs = 500 }: { busy: boolean; delayMs?: n
return (
<p
role="status"
className="fixed top-3 left-1/2 z-40 -translate-x-1/2 rounded-full border border-cyan-400/50 bg-space-900/95 px-4 py-1.5 font-display text-sm text-cyan-100 shadow-panel"
className="fixed top-3 left-1/2 z-40 -translate-x-1/2 rounded-full border border-glass-line bg-white/5 px-4 py-1.5 font-display text-sm text-ink-100 shadow-panel"
>
Catching up on your fleet…
</p>

View File

@ -70,7 +70,7 @@ function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) {
<div className="space-y-3 text-sm">
<p className="text-ink-100">{fill(def.text, { pilot })}</p>
{ship && <p className="text-ink-400">{ship}</p>}
<p aria-label="Time to answer" className="text-amber-200">
<p aria-label="Time to answer" className="text-ink-100">
<Icon name="clock" className="mr-1.5 inline size-4 align-[-3px]" />
Answer within <Countdown to={call.windowEndsAt} format={formatClock} done="now" /> — then{" "}
{pilot} will {fallback?.label.toLowerCase()}.
@ -86,7 +86,7 @@ function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) {
disabled={!available || answer.isPending}
onClick={() => answer.mutate({ dispatchId: call.dispatchId, optionId: option.id })}
>
<span className="font-display tracking-wide">
<span className="font-display">
{option.label}
{option.id === call.defaultOptionId && " (if you don't answer)"}
</span>
@ -151,7 +151,7 @@ export function RadioCallsPanel() {
if (state.radioCalls.length === 0) return null;
return (
<Panel tone="amber" className="text-sm" aria-label="Radio calls">
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-amber-100">
<p className="flex items-center gap-2 font-display font-semibold text-ink-100">
<Icon name="alert" className="size-4" />
Radio calls ({state.radioCalls.length})
</p>

View File

@ -29,7 +29,7 @@ export function RouteStrip({
{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"
className="absolute top-1/2 left-1/2 size-4 -translate-1/2 rounded-full bg-space-900 text-ink-300"
/>
) : (
<>
@ -77,7 +77,7 @@ function Stop({ id, size }: { id: string; size: "sm" | "md" }) {
{/* 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)]"
className="mt-0.5 max-w-full truncate font-display text-xs font-semibold text-ink-300 after:content-[attr(data-label)]"
/>
</div>
);

View File

@ -80,7 +80,7 @@ export function ServiceTag({ ship }: { ship: Ship }) {
<p
role="status"
aria-label={`${ship.name} service`}
className={`rounded-lg border px-3 py-1.5 text-sm ${warn ? "border-amber-400/40 bg-amber-950/30 text-amber-100" : "border-cyan-400/40 bg-cyan-950/30 text-cyan-100"}`}
className={`rounded-lg border px-3 py-1.5 text-sm ${warn ? "border-amber-400/40 bg-amber-950/30 text-ink-100" : "border-glass-line bg-white/5 text-ink-100"}`}
>
{tag}
</p>
@ -149,7 +149,7 @@ function BookingForm({ ship, onDone }: { ship: Ship; onDone: () => void }) {
checked={repair && canRepair}
disabled={!canRepair}
onChange={(e) => setRepair(e.target.checked)}
className="size-4 accent-amber-400"
className="size-4 accent-action"
/>
Repair to 100%
{!canRepair && <span className="text-ink-400">(hull is whole)</span>}
@ -160,7 +160,7 @@ function BookingForm({ ship, onDone }: { ship: Ship; onDone: () => void }) {
aria-label="Upgrade"
value={upgradeId ?? ""}
onChange={(e) => setUpgradeId(e.target.value || null)}
className="mt-1 block w-full rounded-lg border border-space-600 bg-space-950 px-2 py-1.5"
className="mt-1 block w-full rounded-lg border border-glass-line bg-space-950 px-2 py-1.5"
>
<option value="">None</option>
{upgrades.map((u) => (
@ -176,7 +176,7 @@ function BookingForm({ ship, onDone }: { ship: Ship; onDone: () => void }) {
aria-label="HQ"
value={hqId}
onChange={(e) => setHqId(e.target.value)}
className="mt-1 block w-full rounded-lg border border-space-600 bg-space-950 px-2 py-1.5"
className="mt-1 block w-full rounded-lg border border-glass-line bg-space-950 px-2 py-1.5"
>
{hqs.map((id) => (
<option key={id} value={id}>
@ -221,14 +221,14 @@ function AutoRepair({ ship }: { ship: Ship }) {
const { minPct, maxPct, stepPct } = content.config.autoRepair;
const { on, thresholdPct } = ship.autoRepair;
return (
<div className="mt-3 border-t border-space-700 pt-3">
<div className="mt-3 border-t border-glass-line pt-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={on}
disabled={save.isPending}
onChange={(e) => save.mutate({ shipId: ship.id, on: e.target.checked, thresholdPct })}
className="size-4 accent-amber-400"
className="size-4 accent-action"
/>
Auto-repair below {thresholdPct}%
</label>
@ -241,7 +241,7 @@ function AutoRepair({ ship }: { ship: Ship }) {
value={thresholdPct}
disabled={!on}
onChange={(e) => save.mutate({ shipId: ship.id, on, thresholdPct: Number(e.target.value) })}
className="mt-2 w-full accent-amber-400"
className="mt-2 w-full accent-action"
/>
<p className="mt-1 text-xs text-ink-400">
With an Independent pilot, a repair is booked at the nearest HQ when the hull drops below
@ -272,10 +272,10 @@ export function ServiceSection({ shipId }: { shipId: string }) {
return (
<section
aria-label="Service"
className="mt-4 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
className="mt-4 rounded-xl border border-glass-line bg-white/5 p-3 text-sm"
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">Service</span>
<span className="font-display font-bold">Service</span>
{booking && (
<span className="text-ink-400">{booking.by === "auto" ? "Auto" : "Booked"}</span>
)}

View File

@ -74,7 +74,7 @@ export function SkillPointPrompt() {
{ready.map((pilot, i) => (
<span key={pilot.id}>
{i > 0 && " · "}
<strong className="font-semibold text-cyan-200">{callName(pilot)}:</strong>{" "}
<strong className="font-semibold text-ink-100">{callName(pilot)}:</strong>{" "}
{pilot.skillPointsAvailable} {pilot.skillPointsAvailable === 1 ? "point" : "points"}
</span>
))}

View File

@ -22,8 +22,8 @@ function useViewportWidth(): number {
const TONES: Record<Toast["tone"], string> = {
good: "border-emerald-400/40 text-emerald-50",
info: "border-cyan-400/40 text-cyan-50",
warn: "border-amber-400/50 text-amber-50",
info: "border-glass-line text-ink-50",
warn: "border-action/50 text-ink-50",
};
/**
@ -80,7 +80,7 @@ function ToastItem({
onTouchEnd={() => setPaused(false)}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
className={`pointer-events-auto flex items-start gap-2 rounded-xl border bg-space-900/95 py-2 pr-1.5 pl-3 text-sm shadow-panel backdrop-blur ${TONES[toast.tone]} ${motion ? (desktop ? "animate-slide" : "animate-rise") : ""}`}
className={`pointer-events-auto flex items-start gap-2 rounded-xl border bg-white/5 py-2 pr-1.5 pl-3 text-sm shadow-panel backdrop-blur ${TONES[toast.tone]} ${motion ? (desktop ? "animate-slide" : "animate-rise") : ""}`}
>
<button type="button" onClick={open} className="min-w-0 flex-1 py-0.5 text-left">
{toast.text}
@ -136,7 +136,7 @@ export function Toasts() {
dropOverflow();
void navigate("/logbook");
}}
className="pointer-events-auto self-end rounded-full border border-space-600 bg-space-900/95 px-3 py-1 font-display text-xs tracking-wide text-ink-200 shadow-panel"
className="pointer-events-auto self-end rounded-full border border-glass-line bg-white/5 px-3 py-1 font-display text-xs text-ink-200 shadow-panel"
>
+{more} more
</button>

View File

@ -66,7 +66,7 @@ export function WelcomeCards({
};
return createPortal(
<div className="fixed inset-0 z-40 flex items-end justify-center bg-space-950/80 backdrop-blur-sm sm:items-center sm:p-4">
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/25 backdrop-blur-sm sm:items-center sm:p-4">
<div
ref={dialog}
role="dialog"
@ -85,19 +85,13 @@ export function WelcomeCards({
if (from - to > 50) advance();
else if (to - from > 50 && index > 0) setIndex(index - 1);
}}
className="w-full max-w-md overflow-hidden rounded-t-2xl border border-amber-400/50 bg-space-900/95 p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl"
className="w-full max-w-md overflow-hidden rounded-t-2xl border border-glass-line bg-space-850 p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl"
>
<p
aria-live="polite"
className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase"
>
<p aria-live="polite" className="font-display text-xs font-semibold text-ink-300">
{index + 1} of {cards.length}
</p>
<div key={index} data-testid="welcome-card" className={reduced ? "" : "animate-slide"}>
<h2
id="welcome-title"
className="mt-2 font-display text-2xl font-bold tracking-wide text-amber-300"
>
<h2 id="welcome-title" className="mt-2 font-title text-lg leading-snug">
{card.title}
</h2>
<p className="mt-3 text-ink-200">{welcomeBody(card.body)}</p>
@ -106,7 +100,7 @@ export function WelcomeCards({
{cards.map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full ${i === index ? "w-5 bg-amber-300" : "w-1.5 bg-space-600"}`}
className={`h-1.5 rounded-full ${i === index ? "w-5 bg-action" : "w-1.5 bg-white/25"}`}
/>
))}
</div>

View File

@ -9,6 +9,7 @@ import {
MS_PER_HOUR,
routeDistance,
travelTimeGameHours,
pilotShare,
tripFuel,
type EffectiveStats,
type ShipState,
@ -193,6 +194,27 @@ export function readyShips(
);
}
/**
* What a job leaves the company on one ship (decision 0.5.169): the fuel at the
* tank's average price (0.5.98) and a hired pilot's share (CR-010).
*/
export function jobMoney(
ship: Pick<Ship, "modelId" | "upgrades" | "fuelAvgPrice">,
pilot: Pick<Pilot, "isAvatar"> | null | undefined,
contract: Pick<Contract, "originId" | "destinationId" | "payout">,
) {
const fuelFu = tripFuel(
shipStatsOf(ship),
location(contract.originId),
location(contract.destinationId),
content.config,
);
const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice);
const pilotPay =
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
return { fuelFu, fuelCost, pilotPay, netProfit: contract.payout - fuelCost - pilotPay };
}
/**
* How long a job takes in game hours (the trip's travel and handling), for the
* player's fastest ship that could take it: one at the job's origin if any,

View File

@ -1,36 +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";
@import "@fontsource/michroma/latin-400.css";
@import "@fontsource/instrument-sans/latin-400.css";
@import "@fontsource/instrument-sans/latin-500.css";
@import "@fontsource/instrument-sans/latin-600.css";
@import "@fontsource/instrument-sans/latin-700.css";
/* "Freight terminal HUD" theme (CR-005, decisions 0.5.47–0.5.48). */
/* "Deep Field" theme (CR-029, decision 0.5.165). */
@theme {
--font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif;
--font-display: "Chakra Petch", "Barlow", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Instrument Sans", ui-sans-serif, system-ui, sans-serif;
/* The wordmark and screen titles only. */
--font-title: "Michroma", "Instrument Sans", ui-sans-serif, system-ui, sans-serif;
/* Emphasised labels and numbers, used across the screens. */
--font-display: "Instrument Sans", 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;
--color-space-950: #050919;
--color-space-900: #0a1228;
--color-space-850: #0d152e;
--color-space-800: #121c3a;
--color-space-700: #1c284e;
--color-space-600: #2a3863;
--color-space-500: #3f4f81;
/* Text. */
--color-ink-50: #f2f5ff;
--color-ink-200: #c9d3ef;
--color-ink-300: #a3afd3;
--color-ink-400: #7e8bb3;
--color-ink-500: #5c688f;
/* Text. Every step stays readable on the glass, whatever is behind it. */
--color-ink-50: #f0f3ff;
--color-ink-100: #e2e7f7;
--color-ink-200: #d3daef;
--color-ink-300: #b9c4e0;
--color-ink-400: #abb6d4;
--color-ink-500: #a0accd;
--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);
/* Roles (decision 0.5.165). */
--color-glass: rgb(13 21 46 / 0.82);
--color-glass-line: rgb(170 195 255 / 0.18);
--color-action: #ffc04d; /* The main action, and the player's own ship. */
--color-on-action: #241700;
--color-flight: #93c9ff; /* Other ships' flights. */
--color-earned: #84e3b5; /* Money earned. */
--color-problem: #ff8d7a; /* Something needs attention. */
--color-on-problem: #2a0a05;
/* Accent scales in the role hues: amber action, blue flight, mint earned, coral problem. */
--color-amber-50: #fff8e8;
--color-amber-100: #fff0d1;
--color-amber-200: #ffe2a8;
--color-amber-300: #ffd27a;
--color-amber-400: #ffc04d;
--color-amber-500: #f5a623;
--color-amber-600: #c97f10;
--color-amber-700: #9c620f;
--color-amber-800: #7a4d10;
--color-amber-900: #5c3b10;
--color-amber-950: #2e1c04;
--color-cyan-50: #eef6ff;
--color-cyan-100: #dcedff;
--color-cyan-200: #bddcff;
--color-cyan-300: #93c9ff;
--color-cyan-400: #68aef5;
--color-cyan-500: #4690e0;
--color-cyan-600: #2f74c2;
--color-cyan-700: #2a5c98;
--color-cyan-800: #244a78;
--color-cyan-900: #1f3d61;
--color-cyan-950: #122540;
--color-sky-50: #eef6ff;
--color-sky-100: #dcedff;
--color-sky-200: #bddcff;
--color-sky-300: #93c9ff;
--color-sky-400: #68aef5;
--color-sky-500: #4690e0;
--color-sky-600: #2f74c2;
--color-sky-700: #2a5c98;
--color-sky-800: #244a78;
--color-sky-900: #1f3d61;
--color-sky-950: #122540;
--color-emerald-50: #eafbf3;
--color-emerald-100: #d0f6e4;
--color-emerald-200: #aaf0cf;
--color-emerald-300: #84e3b5;
--color-emerald-400: #5ed09a;
--color-emerald-500: #3db680;
--color-emerald-600: #2c9367;
--color-emerald-700: #267553;
--color-emerald-800: #215d44;
--color-emerald-900: #1c4c39;
--color-emerald-950: #0e2c21;
--color-rose-50: #fff2ef;
--color-rose-100: #ffe2db;
--color-rose-200: #ffc8bb;
--color-rose-300: #ffa896;
--color-rose-400: #ff8d7a;
--color-rose-500: #f56f5a;
--color-rose-600: #dc5340;
--color-rose-700: #b54232;
--color-rose-800: #8f372b;
--color-rose-900: #732f26;
--color-rose-950: #3f1610;
/* Selection rings, without the old neon blur. */
--shadow-glow-amber: 0 0 0 1px rgb(255 192 77 / 0.55);
--shadow-glow-cyan: 0 0 0 1px rgb(147 201 255 / 0.5);
--shadow-glow-emerald: 0 0 0 1px rgb(132 227 181 / 0.5);
--shadow-panel: 0 10px 30px -14px rgb(0 0 0 / 0.7);
--animate-rise: rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both;
--animate-glow: glow 2.4s ease-in-out infinite;
@ -88,20 +159,8 @@
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: 2px solid var(--color-action);
outline-offset: 2px;
}
}

View File

@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import { Link, NavLink, Outlet } from "react-router";
import type { CSSProperties, ReactNode } from "react";
import { Link, Outlet, useLocation } from "react-router";
import { GameClock } from "../clock/GameClock";
import { SCREENS, type ScreenPath } from "../routes";
import { SCREENS, tabOf, type ScreenPath } from "../routes";
import { Icon } from "../ui";
import { useTrackScreen } from "./lastScreen";
import { MapBackdrop } from "./MapBackdrop";
import { panelIsWide, PanelHost } from "./PanelHost";
export interface LayoutProps {
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
@ -16,9 +18,9 @@ export interface 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.
* The shell (CR-029): the map behind everything, a floating glass header with
* credits and game clock, the current screen in a panel, and icon navigation:
* a bottom bar on phones, a floating rail from `md`.
*/
export function Layout({
credits = "—",
@ -27,55 +29,52 @@ export function Layout({
attention = {},
}: LayoutProps) {
useTrackScreen();
const { pathname } = useLocation();
const panelWidth = panelIsWide(pathname) ? "45rem" : "26.25rem";
return (
<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
style={{ "--panel-w": panelWidth } as CSSProperties}
className="fixed inset-0 overflow-hidden bg-space-950 text-ink-50 [--nav-h:calc(4rem+env(safe-area-inset-bottom))] md:[--nav-h:0px]"
>
<MapBackdrop />
<header className="absolute inset-x-2 top-[max(0.5rem,env(safe-area-inset-top))] z-10 flex h-12 items-center justify-between gap-2 rounded-2xl border border-glass-line bg-glass px-3 md:top-4 md:right-[calc(var(--panel-w)+2rem)] md:left-24 md:backdrop-blur-xl">
{/* The wordmark only fits beside the chips from 640 px; on phones the mark alone. */}
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<LogoMark />
<span className="hidden font-display text-lg font-bold tracking-[0.28em] sm:inline">
DEADHEAD
</span>
<span className="hidden font-display text-[10px] tracking-[0.3em] text-cyan-300/70 uppercase lg:inline">
Space Freight
</span>
<span className="hidden font-title text-sm sm:inline">DEADHEAD</span>
</div>
<div className="flex shrink-0 items-center gap-1.5 sm: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">
<div className="flex shrink-0 items-center gap-3">
<span className="flex items-center gap-1.5 text-ink-300">
<span aria-label="Game clock" className="text-[13px] tabular-nums">
{clock}
</span>
</div>
</span>
<span aria-label="Credits" className="text-[15px] font-semibold tabular-nums">
{credits}
</span>
<Link
to="/settings"
aria-label="Settings"
className="grid size-8 place-items-center rounded-lg border border-space-600 bg-space-900/80 text-ink-300 transition hover:border-amber-400/60 hover:text-amber-200 sm:size-9"
className="-mr-1.5 grid size-11 place-items-center rounded-lg text-ink-300 transition hover:bg-white/8 hover:text-ink-50"
>
<Icon name="gear" className="size-5" />
</Link>
</div>
</header>
{banner}
{banner && (
<div className="absolute inset-x-2 top-16 z-10 overflow-hidden rounded-xl md:top-20 md:right-[calc(var(--panel-w)+2rem)] md:left-24">
{banner}
</div>
)}
<main className="flex-1 overflow-x-hidden pb-[calc(var(--nav-h)+1.5rem)]">
<PanelHost>
<Outlet />
</main>
</PanelHost>
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-8 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-0.5 md:overflow-y-auto md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4"
className="absolute inset-x-0 bottom-0 z-10 flex h-(--nav-h) border-t border-glass-line bg-space-950/95 pb-[env(safe-area-inset-bottom)] md:top-4 md:right-auto md:bottom-4 md:left-4 md:h-auto md:w-16 md:flex-col md:gap-1 md:overflow-y-auto md:rounded-3xl md:border md:bg-glass md:py-2 md:backdrop-blur-xl"
>
<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) => (
<NavTab
key={screen.path}
@ -83,6 +82,7 @@ export function Layout({
label={screen.label}
icon={screen.icon}
badge={attention[screen.path] ?? 0}
active={tabOf(pathname) === screen.path}
/>
))}
</nav>
@ -95,54 +95,36 @@ function NavTab({
label,
icon,
badge,
active,
}: {
path: ScreenPath;
label: string;
icon: (typeof SCREENS)[number]["icon"];
badge: number;
active: boolean;
}) {
return (
<NavLink
<Link
to={path}
end={path === "/"}
className={({ isActive }) =>
`group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-1.5 ${
isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50"
}`
}
aria-current={active ? "page" : undefined}
className={`relative flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors md:flex-none md:py-2 ${
active ? "text-action" : "text-ink-300 hover:text-ink-50"
}`}
>
{({ isActive }) => (
<>
<span className="relative grid size-6 place-items-center">
<Icon name={icon} className="size-5.5" />
{badge > 0 && (
<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-11 ${
isActive
? "bg-amber-500/15 shadow-glow-amber"
: "group-hover:bg-white/5 group-active:scale-95"
}`}
data-testid={`badge-${label}`}
className="absolute -top-1.5 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-problem px-1 text-xs leading-none font-bold text-on-problem"
>
<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>
)}
{badge > 9 ? "9+" : badge}
</span>
<span className="font-display text-[10px] font-semibold tracking-wide uppercase sm:text-[11px] sm:tracking-wider">
{label}
</span>
</>
)}
</NavLink>
)}
</span>
{label}
</Link>
);
}

View File

@ -0,0 +1,47 @@
import { lazy, Suspense } from "react";
import { useLocation, useNavigate } from "react-router";
import { resetMapView } from "../map/camera";
import { useMapSelection } from "../map/selection";
import { useLoadedPlayer } from "../state/actions";
import { buttonClass } from "../ui";
// The map pulls in PixiJS: load it after the first paint.
const MapCanvas = lazy(() => import("../map/MapCanvas").then((m) => ({ default: m.MapCanvas })));
/**
* The one map canvas of the session, behind every screen (decision 0.5.166).
* A tap on a place or ship opens it in the map view.
*/
export function MapBackdrop() {
const state = useLoadedPlayer();
const { pathname } = useLocation();
const navigate = useNavigate();
const selection = useMapSelection((s) => s.selection);
const select = useMapSelection((s) => s.select);
const onMap = pathname === "/map";
return (
<div className="absolute inset-0 z-0">
<Suspense fallback={null}>
<MapCanvas
state={state}
focused={onMap}
selected={selection}
onSelect={(next) => {
select(next);
if (next && !onMap) void navigate("/map");
}}
/>
</Suspense>
{onMap && (
<button
type="button"
onClick={resetMapView}
className={`absolute top-[calc(max(0.5rem,env(safe-area-inset-top))+3.5rem)] right-2 md:top-20 md:right-[calc(var(--panel-w)+2rem)] ${buttonClass("secondary", "sm")} bg-glass`}
>
Reset view
</button>
)}
</div>
);
}

View File

@ -0,0 +1,152 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useLocation } from "react-router";
import { useMapFrame } from "../map/camera";
type Detent = "peek" | "half" | "full";
/** Screens that open tall on phones (decision 0.5.166). */
const OPEN_FULL = new Set(["/settings", "/logbook", "/pilots", "/hqs", "/locations", "/market"]);
/** Screens with extra columns: a wider panel on desktop. */
const WIDE = new Set(["/contracts", "/logbook"]);
const PEEK_PX = 96;
const HALF = 0.56;
const HEADER_PX = 64;
const NAV_PX = 64;
const detentPx = (detent: Detent, height: number) =>
detent === "peek"
? PEEK_PX
: detent === "half"
? Math.round(height * HALF)
: height - HEADER_PX - NAV_PX;
export const panelIsWide = (pathname: string) => WIDE.has(pathname);
/**
* The glass panel screens live in (decision 0.5.166): a bottom sheet with drag
* detents on phones, a right-hand column from `md`. On `/map` it holds only the
* selected place or ship. It tells the map where the uncovered area is.
*/
export function PanelHost({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
const onMap = pathname === "/map";
const [detent, setDetent] = useState<Detent>(OPEN_FULL.has(pathname) ? "full" : "half");
const [dragPx, setDragPx] = useState<number | null>(null);
const hostRef = useRef<HTMLElement>(null);
const drag = useRef<{ startY: number; startPx: number; moved: number } | null>(null);
// Each screen opens at its own height.
useEffect(() => {
setDetent(OPEN_FULL.has(pathname) ? "full" : "half");
}, [pathname]);
// Keep the map centered in what the panel leaves showing.
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
const measure = () => {
const rect = host.getBoundingClientRect();
const { innerWidth: width, innerHeight: height } = window;
const desktop = width >= 768;
const left = desktop ? 96 : 0;
const right = desktop ? rect.left : width;
const top = desktop ? 0 : HEADER_PX;
const bottom = desktop ? height : onMap ? height - NAV_PX : rect.top;
useMapFrame.setState({
offsetX: (left + right) / 2 - width / 2,
offsetY: (top + bottom) / 2 - height / 2,
covered: !desktop && bottom - top < 40,
});
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(host);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [onMap, pathname]);
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
drag.current = {
startY: e.clientY,
startPx: detentPx(detent, window.innerHeight),
moved: 0,
};
};
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!drag.current) return;
const delta = drag.current.startY - e.clientY;
drag.current.moved = Math.max(drag.current.moved, Math.abs(delta));
if (drag.current.moved > 4) {
const max = detentPx("full", window.innerHeight);
setDragPx(Math.max(PEEK_PX, Math.min(max, drag.current.startPx + delta)));
}
};
const onPointerUp = () => {
if (dragPx !== null) {
const details: Detent[] = ["peek", "half", "full"];
const nearest = details.reduce((best, d) =>
Math.abs(detentPx(d, window.innerHeight) - dragPx) <
Math.abs(detentPx(best, window.innerHeight) - dragPx)
? d
: best,
);
setDetent(nearest);
}
setDragPx(null);
drag.current = null;
};
// A tap or the keyboard steps through the heights, so dragging is never required.
const step = () => {
if (drag.current && drag.current.moved > 4) return;
setDetent((d) => (d === "half" ? "full" : d === "full" ? "peek" : "half"));
};
const wide = WIDE.has(pathname);
if (onMap) {
return (
<main
ref={hostRef}
className="pointer-events-none absolute inset-x-2 bottom-[calc(var(--nav-h)+0.5rem)] z-10 md:top-4 md:right-4 md:bottom-auto md:left-auto md:w-(--panel-w)"
>
{children}
</main>
);
}
return (
<main
ref={hostRef}
style={
{
"--sheet-h": `${dragPx ?? detentPx(detent, globalThis.innerHeight ?? 800)}px`,
} as React.CSSProperties
}
className={`absolute inset-x-0 bottom-(--nav-h) z-10 flex h-(--sheet-h) flex-col rounded-t-3xl border-t border-glass-line bg-space-850/97 shadow-panel md:bg-glass md:top-4 md:right-4 md:bottom-auto md:left-auto md:h-auto md:max-h-[calc(100%-2rem)] md:w-(--panel-w) md:rounded-3xl md:border md:backdrop-blur-xl ${
dragPx === null ? "transition-[height] duration-300 ease-out" : ""
}`}
>
<button
type="button"
aria-label={`Panel height: ${detent}. Change panel height`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onClick={step}
className="-mb-4 flex h-11 shrink-0 touch-none items-start justify-center pt-2.5 md:hidden"
>
<span aria-hidden="true" className="h-1 w-9 rounded-full bg-white/30" />
</button>
<div
className={`@container min-h-0 flex-1 overflow-y-auto overscroll-contain ${wide ? "" : ""}`}
>
{children}
</div>
</main>
);
}

View File

@ -1,18 +1,13 @@
import type { PlayerState } from "@deadhead/schema";
import { readyShips } from "../game";
import { needsYou } from "../components/NeedsYou";
import type { ScreenPath } from "../routes";
/**
* Counts for the navigation's attention badges (decision 0.5.49): goals ready
* to claim and radio calls waiting (decision 0.5.144), ships ready for a job (decision 0.5.134), and unspent skill points
* across all pilots (decision 0.5.109).
* Counts for the navigation's badges: what the Bridge's Needs you list holds,
* per tab (decisions 0.5.49, 0.5.168). Decorative; the list says what they are.
*/
export function tabAttention(state: PlayerState): Partial<Record<ScreenPath, number>> {
const ready = state.tutorial ? (state.tutorial.runInFlight ? 0 : 1) : readyShips(state).length;
return {
// Goals to claim and radio calls to answer (CR-026).
"/": state.goals.filter((g) => g.status === "ready").length + state.radioCalls.length,
"/contracts": ready,
"/pilots": state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0),
};
const counts: Partial<Record<ScreenPath, number>> = { "/": 0, "/contracts": 0, "/fleet": 0 };
for (const item of needsYou(state)) counts[item.tab] = (counts[item.tab] ?? 0) + item.count;
return counts;
}

View File

@ -3,13 +3,16 @@ import { Application, Assets, type Texture } from "pixi.js";
import { useEffect, useRef, useState } from "react";
import { serverNow } from "../clock/clock";
import { loadArt } from "./art/load";
import { pan, useCamera, zoomAt, type Viewport } from "./camera";
import { framed, pan, useCamera, useMapFrame, zoomAt, type Viewport } from "./camera";
import { hitTest, type MapSelection } from "./hitTest";
import { MapRenderer } from "./renderer";
import { buildScene } from "./scene";
import { mapStyle } from "./style";
import { runWhileVisible } from "./ticker";
/** Frame rate while a panel is in front (decision 0.5.166). */
const BEHIND_PANEL_FPS = 30;
/**
* The system map canvas (Steps 10.1–10.2, CR-003). Loads the map art, then
* draws locations and ships every frame from `state` and the server clock;
@ -19,12 +22,15 @@ export function MapCanvas({
state,
onSelect,
selected = null,
focused = true,
}: {
state: PlayerState;
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
onSelect?: (selection: MapSelection | null) => void;
/** Highlighted with a ring (Step 10C.8). */
selected?: MapSelection | null;
/** The map is what the player is looking at: full frame rate and a loading note. */
focused?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const stateRef = useRef(state);
@ -34,12 +40,21 @@ export function MapCanvas({
const selectedRef = useRef(selected);
selectedRef.current = selected;
const [loading, setLoading] = useState(true);
const tickerRef = useRef<{ maxFPS: number } | null>(null);
const frameRate = focused ? 0 : BEHIND_PANEL_FPS;
const frameRateRef = useRef(frameRate);
frameRateRef.current = frameRate;
useEffect(() => {
if (tickerRef.current) tickerRef.current.maxFPS = frameRate;
}, [frameRate]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let cancelled = false;
let stopTicker = () => {};
let stopFrameWatch = () => {};
let renderer: MapRenderer | null = null;
const app = new Application();
@ -48,6 +63,7 @@ export function MapCanvas({
height: container.clientHeight,
});
const camera = () => framed(useCamera.getState(), useMapFrame.getState());
// Art and renderer load together; missing art falls back to placeholders (0.5.41).
const art = loadArt<Texture>(Assets, window.devicePixelRatio);
void Promise.all([
@ -74,12 +90,29 @@ export function MapCanvas({
map.draw(
buildScene(stateRef.current, serverNow()),
viewport(),
useCamera.getState(),
camera(),
selectedRef.current,
),
);
// Only draw while the page is visible (Step 10C.9).
stopTicker = runWhileVisible(app.ticker);
tickerRef.current = app.ticker;
app.ticker.maxFPS = frameRateRef.current;
// Only draw while the page is visible (Step 10C.9) and not fully covered (0.5.166).
let wanted = false;
const apply = () => {
if (wanted && !useMapFrame.getState().covered) app.ticker.start();
else app.ticker.stop();
};
stopFrameWatch = useMapFrame.subscribe(apply);
stopTicker = runWhileVisible({
start: () => {
wanted = true;
apply();
},
stop: () => {
wanted = false;
apply();
},
});
})
.catch(() => {
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
@ -90,6 +123,13 @@ export function MapCanvas({
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
// pointer that barely moves is a tap, which selects what is under it.
const zoom = (factor: number, anchor: { x: number; y: number }) => {
const frame = useMapFrame.getState();
useCamera.setState((c) => {
const next = zoomAt(framed(c, frame), factor, anchor, viewport());
return { ...next, panX: next.panX - frame.offsetX, panY: next.panY - frame.offsetY };
});
};
const pointers = new Map<number, { x: number; y: number }>();
let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null;
const local = (e: PointerEvent | WheelEvent) => {
@ -118,7 +158,7 @@ export function MapCanvas({
const before = Math.hypot(previous.x - other.x, previous.y - other.y);
const after = Math.hypot(current.x - other.x, current.y - other.y);
const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 };
if (before > 0) useCamera.setState((c) => zoomAt(c, after / before, mid, viewport()));
if (before > 0) zoom(after / before, mid);
}
pointers.set(e.pointerId, current);
};
@ -131,13 +171,13 @@ export function MapCanvas({
return;
}
const scene = buildScene(stateRef.current, serverNow());
onSelectRef.current?.(hitTest(scene, local(e), viewport(), useCamera.getState()));
onSelectRef.current?.(hitTest(scene, local(e), viewport(), camera()));
}
if (tap?.id === e.pointerId) tap = null;
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport()));
zoom(Math.exp(-e.deltaY * 0.001), local(e));
};
container.addEventListener("pointerdown", onDown);
container.addEventListener("pointermove", onMove);
@ -148,6 +188,8 @@ export function MapCanvas({
return () => {
cancelled = true;
stopTicker();
stopFrameWatch();
tickerRef.current = null;
container.removeEventListener("pointerdown", onDown);
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerup", onUp);
@ -163,10 +205,10 @@ export function MapCanvas({
data-testid="map-canvas"
className="relative h-full w-full touch-none overflow-hidden"
>
{loading && (
{loading && focused && (
<p
role="status"
className="absolute inset-x-0 top-4 text-center font-display text-sm tracking-widest text-ink-400 uppercase"
className="absolute inset-x-0 top-4 text-center font-display text-sm text-ink-400"
>
Loading map…
</p>

View File

@ -44,6 +44,21 @@ export function resetMapView(): void {
usePreferences.getState().set({ mapCamera: null });
}
/**
* Where the map's center belongs: panels cover part of the screen, so the view
* centers on what's left (decision 0.5.166). `covered`: nothing is showing.
*/
export const useMapFrame = create<{ offsetX: number; offsetY: number; covered: boolean }>(() => ({
offsetX: 0,
offsetY: 0,
covered: false,
}));
/** The camera shifted by the frame, as drawn and hit-tested. */
export function framed(camera: Camera, frame: { offsetX: number; offsetY: number }): Camera {
return { ...camera, panX: camera.panX + frame.offsetX, panY: camera.panY + frame.offsetY };
}
/** Pixels per map unit at this zoom. */
export function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom;

View File

@ -0,0 +1,11 @@
import { create } from "zustand";
import type { MapSelection } from "./hitTest";
/** What the player last tapped on the map, shown in the map view (decision 0.5.166). */
export const useMapSelection = create<{
selection: MapSelection | null;
select: (selection: MapSelection | null) => void;
}>((set) => ({
selection: null,
select: (selection) => set({ selection }),
}));

View File

@ -1,16 +1,19 @@
import type { IconName } from "./ui/icons";
/** The app's top-level screens, in navigation order. Labels are short to fit eight tabs. */
/** The main tabs, in navigation order (decision 0.5.167). */
export const SCREENS = [
// The Dashboard, "Bridge" in the navigation (CR-020, decision 0.5.118).
{ path: "/", title: "Dashboard", label: "Bridge", icon: "helm" },
{ path: "/map", title: "Map", label: "Map", icon: "map" },
{ path: "/contracts", title: "Contracts", label: "Jobs", icon: "jobs" },
{ path: "/", title: "Bridge", label: "Bridge", icon: "helm" },
{ path: "/contracts", title: "Jobs", label: "Jobs", icon: "jobs" },
{ path: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" },
{ path: "/pilots", title: "Pilots", label: "Pilots", icon: "pilots" },
{ path: "/hqs", title: "HQs", label: "HQs", icon: "hq" },
{ path: "/market", title: "Market", label: "Shop", icon: "market" },
{ path: "/locations", title: "Locations", label: "Places", icon: "places" },
{ path: "/more", title: "More", label: "More", icon: "more" },
] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[];
export type ScreenPath = (typeof SCREENS)[number]["path"];
/** The tab a screen belongs to: Crew sits under Fleet, the map under the Bridge. */
export function tabOf(pathname: string): ScreenPath {
if (pathname === "/" || pathname === "/map") return "/";
if (pathname === "/contracts") return "/contracts";
if (pathname === "/fleet" || pathname === "/pilots") return "/fleet";
return "/more";
}

View File

@ -6,7 +6,7 @@ import { usePreferences } from "../state/preferences";
import { Button, Sheet } from "../ui";
const inputClass =
"w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none";
"min-h-11 w-full rounded-lg border border-glass-line bg-black/25 px-3 py-2 text-sm text-ink-50 focus:border-action focus:outline-none";
/** The company name, editable in place with the server's own rules (decision 0.5.75). */
export function CompanyName() {

View File

@ -12,14 +12,16 @@ export function Contracts() {
const state = useLoadedPlayer();
const [selected, setSelected] = useState<Contract | null>(null);
return (
<Screen className="max-w-4xl">
<Screen>
<ScreenHeader
title="Contracts"
eyebrow={state.tutorial ? content.tutorialCompanyName : "Freight market"}
title="Jobs"
eyebrow={state.tutorial ? content.tutorialCompanyName : undefined}
icon="jobs"
/>
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
{selected && (
<DispatchSheet key={selected.id} contract={selected} onClose={() => setSelected(null)} />
)}
</Screen>
);
}

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import { FleetAsleep } from "../components/FleetSleep";
import { RadioCallsPanel } from "../components/RadioCalls";
import { NeedsYou } from "../components/NeedsYou";
import { DispatchOfficePanel } from "../components/IndependentPilots";
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
import { dispatchProgress } from "@deadhead/sim";
@ -16,18 +16,7 @@ import { formatGameHours, locationName, shipModelName } from "../format";
import { lowFuel } from "../game";
import { ShopRest } from "../components/ShopRest";
import { useLoadedPlayer } from "../state/actions";
import {
Art,
buttonClass,
Eyebrow,
Icon,
Meter,
Panel,
Pill,
Screen,
ScreenHeader,
StatTile,
} from "../ui";
import { Art, buttonClass, Eyebrow, Icon, Meter, Panel, Pill, Screen, ScreenHeader } 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 {
@ -93,159 +82,122 @@ export function Dashboard() {
.sort((a, b) => a.inShopUntil! - b.inShopUntil!);
return (
<Screen className="max-w-5xl">
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
<Screen>
<ScreenHeader title="Bridge" eyebrow={state.company.name} icon="helm" />
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
<FleetAsleep />
{/* Calls waiting for an answer, each reopening its card (CR-026). */}
<RadioCallsPanel />
<div className="grid gap-4 md:grid-cols-3">
<YourShip className="md:col-span-2" />
<Alerts />
{state.tutorial && (
<Panel tone="amber">
<Eyebrow>Contract pilot</Eyebrow>
<h2 className="mt-0.5 text-lg font-semibold">Flying for {content.tutorialCompanyName}</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>
<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>
{!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>
)}
<div className="grid gap-4 @2xl:grid-cols-2">
<YourShip />
<NeedsYou />
</div>
<div className="grid grid-cols-2 gap-3">
<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="grid gap-4 md:grid-cols-2">
<div className="space-y-4">
{state.tutorial && (
<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 {content.tutorialCompanyName}
</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>
<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>
{!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>
)}
<Panel
as="section"
aria-labelledby="on-jobs-title"
tone={onJobs.length ? "cyan" : "default"}
>
<h2
id="on-jobs-title"
className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase"
>
Ships on jobs
</h2>
{onJobs.length === 0 ? (
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
) : (
<ul className="mt-2 space-y-3">
{onJobs.map((d) => {
const name = flightShipName(state, d);
return (
<li key={d.id} aria-label={name} className="text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate">
<span className="font-semibold text-ink-50">{name}</span>{" "}
<span className="text-ink-300">
{locationName(d.originId)} → {locationName(d.destinationId)}
</span>
</span>
<span className="font-display font-bold whitespace-nowrap text-cyan-200">
<Countdown to={d.arrivalAt} />
</span>
</div>
<Meter
label={`${name} trip`}
value={Math.round(dispatchProgress(d, now) * 100)}
tone="cyan"
size="sm"
className="mt-1"
/>
</li>
);
})}
</ul>
)}
</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>
)}
{inShop.length > 0 && (
<Panel aria-label="In the shop" tone="amber">
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
In the shop: {inShop.length}
</h2>
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
{inShop.map((ship) => (
<li key={ship.id}>
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" />
</li>
))}
</ul>
</Panel>
)}
{!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
)}
<section aria-labelledby="on-jobs-title">
<div className="flex items-baseline justify-between">
<h2 id="on-jobs-title" className="text-sm font-semibold">
In flight
</h2>
<span aria-label="Ships in flight" className="text-xs text-ink-300">
{flying} flying · {idle} idle
</span>
</div>
{onJobs.length === 0 ? (
<p className="py-2 text-sm text-ink-300">Nothing in flight.</p>
) : (
<ul className="divide-y divide-glass-line">
{onJobs.map((d) => {
const name = flightShipName(state, d);
return (
<li key={d.id} aria-label={name} className="py-2.5 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate">
<span className="font-medium text-ink-50">{name}</span>{" "}
<span className="text-ink-300">
{locationName(d.originId)} → {locationName(d.destinationId)}
</span>
</span>
<span className="font-semibold whitespace-nowrap tabular-nums">
<Countdown to={d.arrivalAt} />
</span>
</div>
<Meter
label={`${name} trip`}
value={Math.round(dispatchProgress(d, now) * 100)}
tone="cyan"
size="sm"
className="mt-1.5"
/>
</li>
);
})}
</ul>
)}
</section>
<div className="space-y-4">
<DispatchOfficePanel />
{!state.tutorial && (
<Panel className="text-sm" aria-label="Logbook">
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-ink-100">
<Icon name="route" className="size-4 text-cyan-300" />
Logbook
</p>
<p className="mt-1 text-ink-300">
Every job, refuel, rest and upgrade from the last 7 days, pilot by pilot.
</p>
<Link
to="/logbook"
className="mt-2 inline-block font-semibold text-amber-300 underline"
>
Open the Logbook
</Link>
</Panel>
)}
{!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</div>
</div>
{!state.tutorial && resting.length > 0 && (
<section aria-label="Pilot rest" className="flex items-center gap-3 text-sm">
<Icon name="rest" className="size-5 shrink-0 text-flight" />
<p className="min-w-0">
<span className="font-medium">Pilots resting: {resting.length}</span>
<span className="block text-ink-300">
{resting[0]!.name} wakes in <Countdown to={resting[0]!.restEndsAt!} done="now" />
</span>
</p>
</section>
)}
{inShop.length > 0 && (
<section aria-label="In the shop" className="text-sm">
<h2 className="font-semibold">In the shop: {inShop.length}</h2>
<ul className="mt-1 space-y-0.5 text-ink-300">
{inShop.map((ship) => (
<li key={ship.id}>
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name} {ship.shop?.level}{" "}
· ready in <Countdown to={ship.inShopUntil!} done="now" />
</li>
))}
</ul>
</section>
)}
{!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-ink-300">No ships yet — buy one in the Shipyard.</p>
)}
{!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
<DispatchOfficePanel />
{!state.tutorial && (
<Link
to="/logbook"
aria-label="Open the Logbook"
className="flex min-h-11 items-center gap-2 text-sm font-medium text-ink-200"
>
<Icon name="route" className="size-4 text-ink-300" />
Open the Logbook
</Link>
)}
</Screen>
);
}
@ -258,12 +210,12 @@ function YourShip({ className = "" }: { className?: string }) {
if (mine.kind === "none") {
return (
<Panel as="section" aria-label="Your ship" feature className={className}>
<Panel as="section" aria-label="Your ship" className={className}>
<Eyebrow>Your ship</Eyebrow>
<p className="mt-2 text-sm text-ink-200">You&apos;re not flying a ship right now.</p>
<Link to="/pilots" className={`mt-3 ${buttonClass("secondary", "sm")}`}>
<Icon name="pilots" className="size-4" />
Assign yourself on Pilots
Assign yourself in Crew
</Link>
</Panel>
);
@ -285,13 +237,12 @@ function YourShip({ className = "" }: { className?: string }) {
<Panel
as="section"
aria-label="Your ship"
feature
tone={dispatch ? "cyan" : "default"}
className={`flex flex-col gap-3 ${className}`}
tone={dispatch || inShop ? "default" : "amber"}
className={`flex flex-col gap-3 self-start ${className}`}
>
<div className="flex items-center gap-3">
{sprite && (
<div className="grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
<div className="grid size-20 shrink-0 place-items-center">
<Art
sprite={sprite}
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
@ -301,7 +252,7 @@ function YourShip({ className = "" }: { className?: string }) {
<div className="min-w-0 flex-1">
<Eyebrow>Your ship</Eyebrow>
<div className="flex items-start justify-between gap-2">
<h2 className="truncate font-display text-xl font-bold tracking-wide">{name}</h2>
<h2 className="truncate text-lg font-semibold">{name}</h2>
{dispatch ? (
<Pill tone="cyan" icon="send">
In flight
@ -335,7 +286,7 @@ function YourShip({ className = "" }: { className?: string }) {
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
: "Flying empty"}
</span>
<span className="font-display text-lg font-bold text-cyan-200">
<span className="font-semibold">
Arrives in <Countdown to={dispatch.arrivalAt} />
</span>
</div>
@ -348,12 +299,12 @@ function YourShip({ className = "" }: { className?: string }) {
)}
{inShop && (
<p className="font-display text-sm text-amber-200">
<p className="font-display text-sm text-ink-100">
In the shop — ready in <Countdown to={ship!.inShopUntil!} done="now" />
</p>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 @2xl:grid-cols-2">
<div>
<Eyebrow>Fuel</Eyebrow>
{ship ? (
@ -365,10 +316,8 @@ function YourShip({ className = "" }: { className?: string }) {
aria-label="Your ship low fuel"
className="mt-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
>
<span className="font-display font-semibold tracking-wide uppercase">
Low fuel
</span>{" "}
· {locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
<span className="font-display font-semibold">Low fuel</span> ·{" "}
{locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
</p>
)}
</div>
@ -406,12 +355,12 @@ function PilotDuty({ pilot }: { pilot: Pilot }) {
{pilot.name} · Level {pilot.level}
</Eyebrow>
{pilot.restingInShopUntil !== null ? (
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
<p className="mt-1 flex items-center gap-1.5 text-sm text-ink-100">
<Icon name="rest" className="size-4 shrink-0" />
<ShopRest until={pilot.restingInShopUntil} />
</p>
) : pilot.resting && pilot.restEndsAt !== null ? (
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
<p className="mt-1 flex items-center gap-1.5 text-sm text-ink-100">
<Icon name="rest" className="size-4 shrink-0" />
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
</p>
@ -421,7 +370,7 @@ function PilotDuty({ pilot }: { pilot: Pilot }) {
label="Your duty"
value={Math.min(pilot.dutyHours, pilot.dutyLimit)}
max={pilot.dutyLimit}
tone={pilot.dutyHours / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
tone={pilot.dutyHours / pilot.dutyLimit >= 0.85 ? "rose" : "default"}
size="sm"
className="mt-2"
/>
@ -434,23 +383,6 @@ function PilotDuty({ pilot }: { pilot: Pilot }) {
);
}
/** Where in-flight alerts and news will appear; empty for now (decision 0.5.120). */
function Alerts() {
return (
<Panel as="section" aria-labelledby="alerts-title" tone="muted">
<h2
id="alerts-title"
className="flex items-center gap-2 font-display text-sm font-semibold tracking-wide text-ink-50"
>
<Icon name="alert" className="size-4 text-amber-300" />
Alerts
</h2>
<p className="mt-2 text-sm text-ink-300">No alerts.</p>
<p className="mt-1 text-xs text-ink-500">In-flight alerts and news will appear here.</p>
</Panel>
);
}
/** One pip per company run toward the deal. */
function RunPips({ done, total }: { done: number; total: number }) {
return (
@ -458,7 +390,7 @@ function RunPips({ done, total }: { done: number; total: number }) {
{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"}`}
className={`h-2 flex-1 rounded-full ${i < done ? "bg-action" : "bg-white/14"}`}
/>
))}
</div>

View File

@ -10,7 +10,7 @@ import { Button } from "../ui";
const MINUTE = 60_000;
const selectClass =
"min-w-0 flex-1 rounded-lg border border-space-600 bg-space-900 px-2 py-1.5 text-sm text-ink-100";
"min-w-0 flex-1 rounded-lg border border-glass-line bg-space-900 px-2 py-1.5 text-sm text-ink-100";
/**
* Settings → Developer (non-production builds only, CR-011): fast-forward the
@ -113,7 +113,7 @@ export function DevTools() {
{state.pilots.length > 0 && (
<fieldset className="space-y-2">
<legend className="text-xs tracking-wider text-ink-400 uppercase">Pilot</legend>
<legend className="text-xs text-ink-400">Pilot</legend>
<select
aria-label="Pilot"
value={pilotId}
@ -149,7 +149,7 @@ export function DevTools() {
{state.ships.length > 0 && (
<fieldset className="space-y-2">
<legend className="text-xs tracking-wider text-ink-400 uppercase">Ship</legend>
<legend className="text-xs text-ink-400">Ship</legend>
<select
aria-label="Ship"
value={shipId}

View File

@ -5,12 +5,10 @@ import {
contractTripGameHours,
isInShop,
rangeFrom,
pilotShare,
dutyAfterTrip,
LONG_HAUL_SKILL_ID,
needsRest,
realToGame,
tripFuel,
tripGameHours,
validateDispatch,
wouldStrand,
@ -31,9 +29,8 @@ import {
skillName,
} from "../format";
import { requirementsText } from "./LocationBoards";
import { location, tripRealMs, shipStatsOf, simShip } from "../game";
import { jobMoney, location, tripRealMs, shipStatsOf, simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Sheet } from "../ui";
import { goodName, PriorityChip } from "../components/Goods";
import {
@ -221,17 +218,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
contract.destinationId,
contract.massTonnes,
);
const fuelFu = tripFuel(
model,
location(contract.originId),
location(contract.destinationId),
content.config,
);
// The fuel used, at the tank's average price (decision 0.5.98).
const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice);
// Profit-share pay for a hired pilot; the avatar is exempt (CR-010).
const pilotPay =
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
const { fuelFu, fuelCost, pilotPay, netProfit } = jobMoney(ship, pilot, contract);
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
let shortOfFuel = false;
@ -292,7 +279,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu },
shortOfFuel,
pilotPay,
netProfit: contract.payout - fuelCost - pilotPay,
netProfit,
arrivalAt: now + tripMs,
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
priority:
@ -376,6 +363,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
<Sheet
titleId="dispatch-title"
eyebrow="Dispatch"
side
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
>
<p aria-label="Job details" className="mt-1 text-sm text-ink-300">
@ -390,9 +378,6 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
</div>
)}
<div className="mt-3">
<RouteStrip originId={contract.originId} destinationId={contract.destinationId} />
</div>
{options.length === 0 ? (
<p className="mt-3 text-sm text-ink-400">
@ -409,10 +394,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
aria-disabled={blocked}
className={`block rounded-xl border p-3 text-sm transition ${
blocked
? "border-space-700 bg-space-950/40 opacity-70"
? "border-glass-line bg-black/25 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"
? "cursor-pointer border-action bg-action/8"
: "cursor-pointer border-glass-line bg-white/5 hover:border-glass-line"
}`}
>
<div className="flex items-center gap-3">
@ -423,18 +408,16 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
disabled={blocked}
checked={picked}
onChange={() => setChosen(option.shipId)}
className="size-4 shrink-0 accent-amber-400"
className="size-4 shrink-0 accent-action"
/>
<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 font-semibold">{option.shipName}</span>
<span className="block text-xs text-ink-400">
{option.modelName} · {option.pilotName ?? "no pilot"}
</span>
{option.service && (
<span aria-label="Service" className="block text-xs text-cyan-200">
<span aria-label="Service" className="block text-xs text-ink-100">
{option.service}
</span>
)}
@ -452,8 +435,13 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
))}
</ul>
) : (
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
<Cell label="Trip" value={formatDuration(option.tripMs)} />
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2">
<div className="col-span-2">
<dt className="text-xs font-semibold text-ink-300">Net profit</dt>
<dd className="text-3xl leading-tight font-semibold text-earned tabular-nums">
{formatCredits(option.netProfit)}
</dd>
</div>
<Cell
label="Fuel used (at your average price)"
value={formatCredits(option.fuelCost)}
@ -462,7 +450,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
!state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && (
<Cell label="Pilot share" value={formatCredits(option.pilotPay)} />
)}
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
<Cell label="Trip" value={formatDuration(option.tripMs)} />
<Cell
label="ETA (game)"
value={formatGameTime(
@ -495,7 +483,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
/>
)}
{!blocked && option.priority && (
<p aria-label="Priority margin" className="mt-2 text-amber-200">
<p aria-label="Priority margin" className="mt-2 text-ink-100">
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
trip,{" "}
{formatGameHoursSpan(
@ -534,15 +522,11 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
}
/** One number in a pair's outlook; `dt` directly before `dd` ("Trip2h 05m"). */
function Cell({ label, value, good }: { label: string; value: string; good?: boolean }) {
function Cell({ label, value }: { label: string; value: string }) {
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>
<dt className="text-xs text-ink-300">{label}</dt>
<dd className="text-sm font-semibold tabular-nums">{value}</dd>
</div>
);
}

View File

@ -7,6 +7,7 @@ import {
validateReposition,
type RepositionFailure,
} from "@deadhead/sim";
import { FleetViews } from "../components/FleetViews";
import { useState } from "react";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
@ -42,7 +43,7 @@ export function Fleet() {
const discoveredIds = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
return (
<Screen className="max-w-4xl">
<Screen>
<ScreenHeader
title="Fleet"
eyebrow={
@ -52,19 +53,20 @@ export function Fleet() {
}
icon="fleet"
/>
<FleetViews />
{state.ships.length === 0 ? (
<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="font-semibold text-amber-300 underline">
visit the Market
<Link to="/market" className="font-semibold underline">
visit the Shipyard
</Link>
.
</p>
</Panel>
) : (
<ul className="grid gap-3 md:grid-cols-2">
<ul className="grid gap-3 @2xl:grid-cols-2">
{state.ships.map((ship) => (
<ShipRow
key={ship.id}
@ -108,19 +110,17 @@ function ShipRow(props: {
const blocked = findWorkBlock(ship, props.pilot, now);
return (
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3">
<Panel as="li" 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%">
<div className="relative grid size-14 shrink-0 place-items-center">
<Art
sprite={model.sprite}
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
className={`size-14 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
/>
</div>
<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>
<span className="truncate text-base font-semibold">{ship.name}</span>
{dispatch ? (
<Pill tone="cyan" icon="send">
In flight
@ -131,14 +131,14 @@ function ShipRow(props: {
<Pill tone="emerald">Idle</Pill>
)}
</div>
<div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div>
<div className="text-xs text-ink-300">{shipModelName(ship.modelId)}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-ink-200">
<span>
{dispatch
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
: `At ${locationName(ship.locationId ?? "")}`}
{" · "}
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
<span className={pilotName ? "text-ink-300" : "text-problem"}>
{pilotName ?? "No pilot"}
</span>
</span>
@ -154,15 +154,17 @@ function ShipRow(props: {
<span>{progress}%</span>
<span>
Arrives in{" "}
<span className="text-cyan-200">
<span className="text-ink-100">
<Countdown to={dispatch.arrivalAt} />
</span>
</span>
</div>
</div>
)}
<FuelGauge ship={ship} />
<HullGauge ship={ship} />
<div className="grid grid-cols-2 gap-3">
<FuelGauge ship={ship} />
<HullGauge ship={ship} />
</div>
<ServiceTag ship={ship} />
{low && (
<p
@ -170,14 +172,14 @@ function ShipRow(props: {
aria-label={`${ship.name} low fuel`}
className="flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
>
<span className="font-display font-semibold tracking-wide uppercase">Low fuel</span>
<span className="font-display font-semibold">Low fuel</span>
<span className="text-rose-200/80">
{locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
</span>
</p>
)}
{inShop && (
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-ink-100">
{ship.repairing ? "Under repair" : "In the shop"} —{" "}
<Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
</p>
@ -217,17 +219,18 @@ function ShipRow(props: {
variant="secondary"
size="sm"
onClick={props.onDeadhead}
className="flex-1 basis-40"
aria-label="Deadhead (fly empty)"
className="flex-1 basis-32"
>
<Icon name="send" className="size-4" />
Deadhead (fly empty)
Fly empty
</Button>
)}
</div>
)}
<div className="flex gap-2">
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={props.onFuel}
aria-label={`Fuel for ${ship.name}`}
@ -236,7 +239,7 @@ function ShipRow(props: {
Fuel
</Button>
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={props.onUpgrades}
aria-label={`Upgrades for ${ship.name}`}
@ -378,14 +381,12 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
{ onSuccess: onClose },
)
}
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"
className="flex w-full items-center gap-3 rounded-xl border border-glass-line bg-white/5 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-display font-bold tracking-wide">
{locationName(option.id)}
</span>
<span className="font-display font-bold">{locationName(option.id)}</span>
<span className="font-display text-sm text-ink-300 tabular-nums">
{formatCredits(option.fuelCost)} fuel used · {formatDuration(option.tripMs)}
</span>
@ -425,7 +426,7 @@ export function HullGauge({ ship }: { ship: Ship }) {
const tone = pct < needsRepairBelowPct ? "rose" : pct < noDangerousBelowPct ? "amber" : "emerald";
return (
<div>
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
<div className="flex items-baseline justify-between font-display text-xs text-ink-400">
<span>Hull</span>
<span className="tracking-normal normal-case">{Math.round(pct)}%</span>
</div>
@ -436,7 +437,7 @@ export function HullGauge({ ship }: { ship: Ship }) {
aria-label={`${ship.name} needs repair`}
className="mt-2 flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
>
<span className="font-display font-semibold tracking-wide uppercase">Needs repair</span>
<span className="font-display font-semibold">Needs repair</span>
<span className="text-rose-200/80">Repair it at an HQ</span>
</p>
)}

View File

@ -1,3 +1,4 @@
import { PlacesViews } from "../components/FleetViews";
import { content } from "@deadhead/content";
import type { Bay } from "@deadhead/schema";
import { Link } from "react-router";
@ -15,13 +16,14 @@ export const HQ_ALLOWS = "Hire pilots here and take delivery of new ships here."
export function HQs() {
const state = useLoadedPlayer();
return (
<Screen className="max-w-4xl">
<Screen>
<ScreenHeader
title="HQs"
eyebrow="Your bases"
icon="hq"
subtitle={state.bays.length > 0 ? HQ_ALLOWS : undefined}
/>
<PlacesViews />
{state.bays.length === 0 ? (
<Panel tone="muted" className="space-y-3 text-sm">
<p className="text-ink-200">You don&apos;t have an HQ yet.</p>
@ -34,7 +36,7 @@ export function HQs() {
</Link>
</Panel>
) : (
<ul className="grid gap-4 md:grid-cols-2">
<ul className="grid gap-4 @2xl:grid-cols-2">
{state.bays.map((bay) => (
<HqCard key={bay.id} bay={bay} />
))}
@ -58,7 +60,7 @@ function HqCard({ bay }: { bay: Bay }) {
);
return (
<Panel as="li" feature tone="emerald" aria-label={`HQ at ${name}`} className="text-sm">
<Panel as="li" aria-label={`HQ at ${name}`} className="text-sm">
<div className="flex items-center gap-4">
{location && (
<Art
@ -67,7 +69,7 @@ function HqCard({ bay }: { bay: Bay }) {
/>
)}
<div className="min-w-0 flex-1">
<h2 className="font-display text-xl font-bold tracking-wide">{name}</h2>
<h2 className="font-display text-xl font-bold">{name}</h2>
<p className="text-ink-300">
Fuel: <FuelPrice locationId={bay.locationId} />
</p>
@ -110,7 +112,7 @@ function HqCard({ bay }: { bay: Bay }) {
{state.ships.find((s) => s.id === d.shipId)?.name ?? "A ship"} from{" "}
{locationName(d.originId)}
</span>
<span className="font-display text-cyan-200">
<span className="font-display text-ink-100">
<Countdown to={d.arrivalAt} />
</span>
</li>

View File

@ -0,0 +1,216 @@
import type { Contract } from "@deadhead/schema";
import { Countdown } from "../components/Countdown";
import { ClassChip, goodName, PriorityChip } from "../components/Goods";
import {
formatClock,
formatCredits,
formatGameHours,
formatMillionKm,
locationName,
} from "../format";
import { distanceAt } from "../game";
import { useClock } from "../clock/clock";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences, type JobSort } from "../state/preferences";
import { Pill } from "../ui";
import { jobFigures, type JobFigures } from "./jobFigures";
import { requirementsText } from "./LocationBoards";
const COLUMNS = "hidden px-2 text-[13px] text-ink-300 @2xl:table-cell";
const money = (n: number) => n.toLocaleString("en-US");
function netLabel({ net, estimate }: JobFigures): string {
if (net === null) return "—";
return `${estimate ? "≈" : ""}${net >= 0 ? "+" : "−"}${money(Math.abs(net))}`;
}
/** Jobs at a place, sortable by net or credits per hour (decision 0.5.169). */
export function JobsTable({
contracts,
fresh,
firstLookMs,
pilotsOnTheirOwn,
onSelect,
}: {
contracts: Contract[];
fresh: ReadonlySet<string>;
firstLookMs: number;
pilotsOnTheirOwn: boolean;
onSelect?: (contract: Contract) => void;
}) {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const sort = usePreferences((p) => p.jobSort);
const setPreference = usePreferences((p) => p.set);
const value = (f: JobFigures) => (sort === "net" ? f.net : f.perHour) ?? -Infinity;
const rows = contracts
.map((contract) => ({ contract, figures: jobFigures(state, contract, now) }))
.sort((a, b) => value(b.figures) - value(a.figures));
const ready = rows.filter((r) => r.figures.ready);
const blocked = rows.filter((r) => !r.figures.ready);
const header = (key: JobSort, label: string, width: string) => (
<th
scope="col"
aria-sort={sort === key ? "descending" : "none"}
className={`p-0 text-right font-medium ${width}`}
>
<button
type="button"
onClick={() => setPreference({ jobSort: key })}
className={`flex min-h-11 w-full items-center justify-end gap-1 pr-3 text-xs font-semibold ${
sort === key ? "text-ink-50" : "text-ink-300"
}`}
>
{label}
{sort === key && <span aria-hidden="true">↓</span>}
</button>
</th>
);
const row = ({ contract, figures }: (typeof rows)[number]) => {
const requirements = requirementsText(contract);
const firstLook =
pilotsOnTheirOwn && !contract.firstContact && now < contract.offeredAt + firstLookMs;
const hours = figures.hours === null ? null : formatGameHours(figures.hours);
const destination = `To ${locationName(contract.destinationId)}`;
return (
<tr
key={contract.id}
aria-label={goodName(contract)}
className={`relative border-t border-glass-line ${
contract.firstContact ? "bg-action/8" : ""
} ${fresh.has(contract.id) ? "animate-rise" : ""}`}
>
<td className="min-w-0 py-2 pr-1 pl-3">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span
className={`text-[14.5px] leading-snug font-semibold ${figures.ready ? "" : "text-ink-300"}`}
>
{goodName(contract)}
</span>
{fresh.has(contract.id) && (
<Pill tone="cyan" icon="star">
New
</Pill>
)}
{contract.cargoTypeId !== "standard" && <ClassChip contract={contract} />}
{contract.priority && contract.deliveryWindowGameHours !== null && (
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
)}
{contract.firstContact && (
<Pill tone="amber" icon="star">
First contact
</Pill>
)}
{firstLook && (
<Pill tone="amber" icon="clock">
<span aria-label="Yours for">
Yours for{" "}
<Countdown
to={contract.offeredAt + firstLookMs}
format={formatClock}
done="now"
/>
</span>
</Pill>
)}
</div>
<div className="text-[12.5px] leading-snug text-ink-300">
{destination}
<span className="@2xl:hidden">
{" "}
· <span>{contract.massTonnes} t</span>
{hours && <span aria-label={`Takes ${hours} of game time`}> · {hours}</span>}
</span>
{requirements && <span className="@2xl:hidden"> · Needs {requirements}</span>}
{figures.note && <span> · {figures.note}</span>}
</div>
{/* The rest of the job: beside the columns on desktop, in the sheet on phones. */}
<div className="hidden text-xs leading-snug text-ink-400 @2xl:block">
Pays <span>{formatCredits(contract.payout)}</span> ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId))} ·{" "}
{requirements && `Needs ${requirements} · `}expires in{" "}
<Countdown to={contract.expiresAt} done="expired" />
</div>
</td>
<td className={COLUMNS}>{contract.shipper}</td>
<td className={`${COLUMNS} tabular-nums`}>{contract.massTonnes} t</td>
<td className={`${COLUMNS} tabular-nums`}>{hours ?? "—"}</td>
<td
aria-label={figures.estimate ? "Net, estimated" : "Net"}
className={`pr-3 text-right text-[15px] font-bold whitespace-nowrap tabular-nums ${
figures.ready ? "text-earned" : "text-ink-300"
}`}
>
{netLabel(figures)}
</td>
<td
aria-label="Credits per hour"
className="pr-3 text-right text-[13.5px] whitespace-nowrap text-ink-300 tabular-nums"
>
{figures.perHour === null ? "—" : money(figures.perHour)}
</td>
{onSelect && (
<td className="w-0 p-0">
{/* The whole row opens the job: the button stretches over it. */}
<button
type="button"
aria-label="Dispatch"
onClick={() => onSelect(contract)}
className="absolute inset-0 rounded-sm hover:bg-white/5 focus-visible:-outline-offset-2"
/>
</td>
)}
</tr>
);
};
return (
<div className="overflow-hidden rounded-2xl border border-glass-line">
<table className="w-full border-collapse text-left">
<caption className="sr-only">Jobs here, best first</caption>
<thead className="bg-black/20">
<tr>
<th scope="col" className="py-0 pl-3 text-xs font-semibold text-ink-300">
Job
</th>
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
Shipper
</th>
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
Weight
</th>
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
Hours
</th>
{header("net", "Net", "w-20")}
{header("rate", "cr/h", "w-16")}
{onSelect && (
<td className="w-0 p-0">
<span className="sr-only">Open</span>
</td>
)}
</tr>
</thead>
<tbody>
{ready.map(row)}
{blocked.length > 0 && (
<tr className="border-t border-glass-line bg-black/20">
<th
scope="colgroup"
colSpan={onSelect ? 7 : 6}
className="px-3 py-1.5 text-xs font-semibold text-ink-300"
>
Can&apos;t take yet
</th>
</tr>
)}
{blocked.map(row)}
</tbody>
</table>
</div>
);
}

View File

@ -5,22 +5,14 @@ import { useSearchParams } from "react-router";
import { useApi } from "../api/context";
import { serverNow, useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown";
import {
formatClock,
formatCredits,
formatGameHours,
formatMillionKm,
locationName,
skillName,
} from "../format";
import { distanceAt, jobGameHours, notReady, readyShips } from "../game";
import { formatClock, locationName, skillName } from "../format";
import { notReady, readyShips } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet";
import { JobsTable } from "./JobsTable";
import { DeadheadPicker } from "./Fleet";
import { content } from "@deadhead/content";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Icon, Panel, Pill } from "../ui";
import { ClassChip, goodName, PriorityChip } from "../components/Goods";
import { Art, Button, Icon, Panel } from "../ui";
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
@ -116,14 +108,14 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
type="button"
aria-pressed={id === locationId}
onClick={() => setParams({ location: id }, { replace: true })}
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"
className="flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border border-glass-line py-1 pr-3 pl-1 text-[13px] font-medium text-ink-300 transition hover:text-ink-50 aria-pressed:border-white/50 aria-pressed:bg-white/14 aria-pressed:text-ink-50"
>
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
{locationName(id)}
{ready > 0 && (
<span
aria-label={`${ready} ${ready === 1 ? "ship" : "ships"} ready`}
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"
className="grid h-5 min-w-5 place-items-center rounded-full bg-action/20 px-1.5 text-xs font-semibold text-action tabular-nums"
>
{ready}
</span>
@ -174,19 +166,16 @@ function LocationBoard({
);
}
const refresh = (
<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" />
<p aria-label="Board refresh" className="flex items-center gap-1.5 text-xs text-ink-300">
<Icon name="clock" className="size-3.5" />
New contracts in{" "}
<span className="text-cyan-200">
<span className="font-semibold text-ink-100 tabular-nums">
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
{fresh.size > 0 && (
<span
role="status"
className="ml-2 animate-rise rounded-full border border-cyan-400/50 bg-cyan-500/15 px-2 py-0.5 tracking-wider text-cyan-100 normal-case"
className="ml-2 animate-rise rounded-full border border-glass-line bg-white/5 px-2 py-0.5 text-ink-100 normal-case"
>
New contracts posted
</span>
@ -200,7 +189,7 @@ function LocationBoard({
<div className="space-y-3">
<Panel tone="muted" role="status" className="text-center text-sm text-ink-300">
No jobs here your fleet can take right now. New contracts in{" "}
<span className="font-display text-cyan-200 tabular-nums">
<span className="font-display text-ink-100 tabular-nums">
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
.
@ -214,110 +203,17 @@ function LocationBoard({
<div className="space-y-3">
{refresh}
{stuck}
<ul className="grid gap-3 sm:grid-cols-2">
{board.data.contracts.map((contract) => {
const requirements = requirementsText(contract);
return (
<Panel
as="li"
key={contract.id}
tone={contract.firstContact ? "amber" : "default"}
feature={contract.firstContact}
className={`flex flex-col gap-2 ${fresh.has(contract.id) ? "animate-rise" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-display text-sm font-semibold tracking-wide text-ink-300">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
</span>
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
{formatCredits(contract.payout)}
</span>
</div>
<div>
<h3 className="font-display text-lg leading-tight font-bold tracking-wide">
{goodName(contract)}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
{fresh.has(contract.id) && (
<Pill tone="cyan" icon="star">
New
</Pill>
)}
<ClassChip contract={contract} />
{contract.priority && contract.deliveryWindowGameHours !== null && (
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
)}
{contract.firstContact && (
<Pill tone="amber" icon="star">
First contact
</Pill>
)}
{pilotsOnTheirOwn &&
!contract.firstContact &&
now < contract.offeredAt + firstLookMs && (
<Pill tone="amber" icon="clock">
<span aria-label="Yours for">
Yours for{" "}
<Countdown
to={contract.offeredAt + firstLookMs}
format={formatClock}
done="now"
/>
</span>
</Pill>
)}
</div>
</div>
<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>
{contract.shipper} · <span>{contract.massTonnes} t</span> ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
<TripHours contract={contract} />
</span>
</div>
<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 size="sm" onClick={() => onSelect(contract)} className="shrink-0">
Dispatch
</Button>
)}
</div>
</Panel>
);
})}
</ul>
<JobsTable
contracts={board.data.contracts}
fresh={fresh}
firstLookMs={firstLookMs}
pilotsOnTheirOwn={pilotsOnTheirOwn}
onSelect={onSelect}
/>
</div>
);
}
/** " · 5.4 h": the job's length in game hours for the fastest ship that could take it. */
function TripHours({ contract }: { contract: Contract }) {
const state = useLoadedPlayer();
const hours = jobGameHours(state, contract);
if (hours === null) return null;
return (
<span
aria-label={`Takes ${formatGameHours(hours)} of game time`}
title="Game hours, for your fastest ship that can take it"
>
{" "}
· {formatGameHours(hours)}
</span>
);
}
/**
* Why no ship here can take a job now (decision 0.5.134): none docked, or the
* pilots here resting (with who wakes first), or none crewed for the player.
@ -330,8 +226,8 @@ function NoneReady({ locationId, now }: { locationId: string; now: number }) {
.flatMap((r) => (r?.reason === "resting" ? [r.pilot] : []))
.sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0];
return (
<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={waking ? "rest" : "alert"} className="size-4 shrink-0 text-amber-300" />
<p className="flex items-center gap-2 rounded-xl border border-glass-line px-3 py-2 text-sm text-ink-300">
<Icon name={waking ? "rest" : "alert"} className="size-4 shrink-0" />
{idle.length === 0 ? (
"No idle ships here. Deadhead one in from the Fleet screen to take these jobs."
) : waking ? (
@ -382,11 +278,10 @@ function NothingHere({
key={ship.id}
role="group"
aria-label={`Nothing for ${ship.name}`}
tone="cyan"
className="text-sm"
>
<p className="flex items-center gap-2 text-cyan-100">
<Icon name="alert" className="size-4 shrink-0 text-cyan-300" />
<p className="flex items-center gap-2 text-ink-100">
<Icon name="alert" className="size-4 shrink-0 text-ink-300" />
No job here that {ship.name} and {pilot.name} can take.
</p>
<div className="mt-3 flex flex-wrap gap-2">

View File

@ -1,3 +1,4 @@
import { PlacesViews } from "../components/FleetViews";
import { content, type Location } from "@deadhead/content";
import { FuelPrice } from "../components/Fuel";
import { Link } from "react-router";
@ -26,14 +27,15 @@ export function Locations() {
const lease = useGameAction("leaseBay");
return (
<Screen className="max-w-4xl">
<ScreenHeader title="Locations" eyebrow="Known space" icon="places" />
<Screen>
<ScreenHeader title="Places" icon="places" />
<PlacesViews />
{lease.isError && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(lease.error)}
</p>
)}
<ul className="grid gap-3 md:grid-cols-2">
<ul className="grid gap-3 @2xl:grid-cols-2">
{content.locations.map((location) => {
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
if (!discovered) {
@ -52,9 +54,7 @@ export function Locations() {
/>
</div>
<div>
<div className="font-display text-lg font-bold tracking-wide text-ink-300">
{location.name}
</div>
<div className="font-display text-lg font-bold text-ink-300">{location.name}</div>
<div className="text-ink-400">Undiscovered — deliver here to unlock.</div>
</div>
</Panel>
@ -76,42 +76,30 @@ export function Locations() {
/>
<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>
<span className="font-display text-xl font-bold">{location.name}</span>
{hasBay && (
<Pill tone="emerald" icon="hq">
HQ
</Pill>
)}
</div>
<span className="font-display text-xs tracking-wider text-cyan-300/80">
<span className="font-display text-xs text-ink-300">
{location.id === "earth"
? "Home"
: `${formatMillionKm(distanceAt("earth", location.id))} from Earth`}
</span>
</div>
</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>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg bg-black/25 p-2.5 text-ink-200 ring-1 ring-glass-line">
<dt className="font-display text-xs text-ink-400">Type</dt>
<dd>{TYPE_LABELS[location.type]}</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Landing
</dt>
<dt className="font-display text-xs text-ink-400">Landing</dt>
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Produces
</dt>
<dt className="font-display text-xs text-ink-400">Produces</dt>
<dd>{location.cargoClassMix.produces.map(cargoName).join(", ") || "—"}</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Demands
</dt>
<dt className="font-display text-xs text-ink-400">Demands</dt>
<dd>{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Fuel
</dt>
<dt className="font-display text-xs text-ink-400">Fuel</dt>
<dd aria-label={`Fuel at ${location.name}`}>
<FuelPrice locationId={location.id} />
</dd>

View File

@ -57,7 +57,7 @@ export function Logbook() {
}
return (
<Screen className="max-w-3xl">
<Screen>
<ScreenHeader title="Logbook" eyebrow="The last 7 days" icon="route" />
<Panel className="flex flex-wrap items-end gap-3 text-sm" aria-label="Logbook filters">
<label className="flex flex-col gap-1">
@ -65,7 +65,7 @@ export function Logbook() {
<select
value={pilotId}
onChange={(e) => set("pilot", e.target.value)}
className="rounded-lg border border-space-600 bg-space-900 px-2 py-1.5"
className="min-h-11 rounded-lg border border-glass-line bg-space-900 px-2"
>
<option value="">All pilots</option>
{state.pilots.map((p) => (
@ -77,11 +77,11 @@ export function Logbook() {
</label>
<fieldset className="flex flex-col gap-1">
<legend className="text-ink-400">Time</legend>
<div className="flex overflow-hidden rounded-lg border border-space-600">
<div className="flex overflow-hidden rounded-lg border border-glass-line">
{RANGES.map(([value, label]) => (
<label
key={value}
className="cursor-pointer px-3 py-1.5 has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
className="cursor-pointer px-3 py-1.5 has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
>
<input
type="radio"

View File

@ -1,15 +1,13 @@
import { content, type Location } from "@deadhead/content";
import { FuelPrice } from "../components/Fuel";
import { realToGame } from "@deadhead/sim";
import { useState, type ReactNode } from "react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown";
import { cargoName, formatMillionKm, locationName } from "../format";
import { distanceAt } from "../game";
import { resetMapView } from "../map/camera";
import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { useMapSelection } from "../map/selection";
import { reachersByOrigin } from "../map/reach";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
@ -24,32 +22,22 @@ const TYPE_LABELS: Record<Location["type"], string> = {
"surface-base": "Surface base",
};
/** The solar system map (Phase 10). Tap a location or ship for details. */
/** The map view (Phase 10): the map itself is the backdrop. Tap a place or ship for details. */
export function MapScreen() {
const state = useLoadedPlayer();
const [selection, setSelection] = useState<MapSelection | null>(null);
const selection = useMapSelection((s) => s.selection);
const select = useMapSelection((s) => s.select);
const close = () => select(null);
return (
// 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={resetMapView}
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
>
Reset view
</button>
{selection?.kind === "location" && (
<LocationPanel locationId={selection.id} onClose={() => setSelection(null)} />
<LocationPanel locationId={selection.id} onClose={close} />
)}
{selection?.kind === "flight" && (
<FlightPanel dispatchId={selection.dispatchId} onClose={() => setSelection(null)} />
<FlightPanel dispatchId={selection.dispatchId} onClose={close} />
)}
</section>
</>
);
}
@ -68,13 +56,13 @@ function Panel({
<div
role="dialog"
aria-label={title}
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"
className="pointer-events-auto animate-sheet rounded-3xl border border-glass-line bg-glass p-4 text-sm shadow-panel md:backdrop-blur-xl"
>
<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-display text-lg font-bold tracking-wide">{title}</h2>
<h2 className="font-display text-lg font-bold">{title}</h2>
<button
type="button"
onClick={onClose}
@ -183,7 +171,7 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: ()
<p className="text-ink-300">Pilot: {pilotName}</p>
<p className="text-ink-300">
Arrives in{" "}
<span className="font-display text-cyan-200">
<span className="font-display text-ink-100">
<Countdown to={dispatch.arrivalAt} />
</span>{" "}
({formatGameTime(realToGame(dispatch.arrivalAt, content.config), clockFormat)})

View File

@ -27,10 +27,9 @@ export function Market() {
const [buying, setBuying] = useState<ShipModel | null>(null);
return (
<Screen className="max-w-5xl">
<Screen>
<ScreenHeader
title="Market"
eyebrow="Shipyard"
title="Shipyard"
icon="market"
subtitle="New ships are delivered with no pilot to one of your HQs."
/>
@ -48,22 +47,21 @@ export function Market() {
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
</p>
)}
<ul className="grid gap-4 md:grid-cols-3">
<ul className="grid gap-4 @2xl:grid-cols-3">
{MODELS.map((model) => {
const affordable = state.company.credits >= model.price;
return (
<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" />
<Panel as="li" key={model.id} className="flex flex-col text-sm">
<div className="relative -mt-1 grid h-32 place-items-center">
<Art
sprite={model.sprite}
className="relative size-32 rotate-[30deg] drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
className="relative size-28 drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
/>
</div>
<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="text-lg font-semibold">{model.name}</span>
<span
className={`font-display text-base font-bold tabular-nums ${affordable ? "text-amber-300" : "text-ink-400"}`}
className={`font-semibold tabular-nums ${affordable ? "text-ink-50" : "text-ink-300"}`}
>
{formatCredits(model.price)}
</span>
@ -94,6 +92,7 @@ export function Market() {
<OwnedUpgraded modelId={model.id} />
<Button
aria-label={`Buy ${model.name}`}
variant="secondary"
block
disabled={!affordable || state.bays.length === 0 || buy.isPending}
onClick={() => setBuying(model)}
@ -122,18 +121,19 @@ export function Market() {
/** 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>
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2">
<dt className="flex items-center gap-1.5 text-xs text-ink-300">
<Icon name={props.icon} className="size-3.5" />
{props.label}
</dt>
<dd className="font-semibold tabular-nums">{props.value}</dd>
{props.share !== undefined && (
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
<div
aria-hidden="true"
className="col-span-2 mt-1 h-1 overflow-hidden rounded-full bg-white/12"
>
<div
className="h-full rounded-full bg-linear-to-r from-cyan-500 to-sky-300"
className="h-full rounded-full bg-ink-300"
style={{ width: `${Math.max(4, props.share * 100)}%` }}
/>
</div>
@ -162,7 +162,7 @@ function Reach({ model }: { model: ShipModel }) {
if (parts.length === 0) return null;
return (
<p aria-label="Reach" className="mt-3 text-xs text-ink-300">
<span className="font-display tracking-[0.14em] text-ink-400 uppercase">Reach </span>
<span className="font-display text-ink-400">Reach </span>
{parts.join(" · ")}
</p>
);
@ -192,14 +192,14 @@ function DeliveryPicker(props: {
return (
<label
key={bay.id}
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"
className="flex cursor-pointer items-center gap-3 rounded-xl border border-glass-line bg-white/5 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"
className="size-4 accent-action"
/>
{sprite && <Art sprite={sprite} className="size-8" />}
<span className="font-display font-semibold">{locationName(bay.locationId)}</span>
@ -230,7 +230,7 @@ function OwnedUpgraded({ modelId }: { modelId: string }) {
);
if (upgraded.length === 0) return null;
return (
<ul aria-label="Your upgraded ships" className="mt-3 space-y-1 text-xs text-cyan-200">
<ul aria-label="Your upgraded ships" className="mt-3 space-y-1 text-xs text-ink-100">
{upgraded.map((ship) => {
const s = shipStatsOf(ship);
return (

View File

@ -0,0 +1,37 @@
import { Link } from "react-router";
import { Icon, Screen, ScreenHeader, type IconName } from "../ui";
const ROWS: { to: string; title: string; detail: string; icon: IconName }[] = [
{ to: "/market", title: "Shipyard", detail: "Compare and buy ships", icon: "market" },
{
to: "/locations",
title: "Places & HQs",
detail: "Where you can fly, and your bases",
icon: "places",
},
{ to: "/logbook", title: "Logbook", detail: "Every job and refuel, 7 days", icon: "route" },
{ to: "/settings", title: "Settings", detail: "Company, display and save", icon: "gear" },
];
/** Everything that isn't one of the main tabs (decision 0.5.167). */
export function More() {
return (
<Screen>
<ScreenHeader title="More" />
<ul className="divide-y divide-glass-line">
{ROWS.map((row) => (
<li key={row.to}>
<Link to={row.to} className="flex min-h-15 items-center gap-3 py-2">
<Icon name={row.icon} className="size-5 shrink-0 text-ink-300" />
<span className="min-w-0 flex-1">
<span className="block font-medium">{row.title}</span>
<span className="block text-[13px] text-ink-300">{row.detail}</span>
</span>
<Icon name="arrow" className="size-4 shrink-0 text-ink-400" />
</Link>
</li>
))}
</ul>
</Screen>
);
}

View File

@ -1,3 +1,4 @@
import { FleetViews } from "../components/FleetViews";
import { content } from "@deadhead/content";
import { PilotIndependence } from "../components/IndependentPilots";
import type { Pilot } from "@deadhead/schema";
@ -28,15 +29,16 @@ export function levelProgress(xp: number, level: number) {
export function Pilots() {
const state = useLoadedPlayer();
return (
<Screen className="max-w-4xl">
<ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
<Screen>
<ScreenHeader title="Crew" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
<FleetViews />
<HirePilot />
{!state.pilots.some((p) => !p.isAvatar) && (
<p className="text-sm text-ink-400">
No hired pilots yet. Hire one at a bay to crew another ship.
</p>
)}
<ul className="grid gap-3 md:grid-cols-2">
<ul className="grid gap-3 @2xl:grid-cols-2">
{state.pilots.map((pilot) => (
<PilotCard key={pilot.id} pilot={pilot} />
))}
@ -53,23 +55,18 @@ function HirePilot() {
const hasBay = state.bays.some((b) => b.locationId === bay);
return (
<Panel feature tone="amber" className="text-sm">
<Panel className="text-sm">
{state.bays.length === 0 ? (
<p className="text-ink-300">Lease a bay to hire pilots there.</p>
) : (
<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="font-display text-xs tracking-wider text-ink-300 uppercase">
Hire at
</span>
<span className="font-display text-xs text-ink-300">Hire at</span>
<select
aria-label="Hire at"
value={bay}
onChange={(e) => setBay(e.target.value)}
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"
className="min-h-11 rounded-lg border border-glass-line bg-black/25 px-3 font-display text-sm focus:border-action focus:outline-none"
>
{state.bays.map((b) => (
<option key={b.id} value={b.locationId}>
@ -79,6 +76,8 @@ function HirePilot() {
</select>
</label>
<Button
variant="secondary"
size="sm"
disabled={!hasBay || state.company.credits < cost || hire.isPending}
onClick={() => hire.mutate({ locationId: bay })}
className="ml-auto"
@ -114,29 +113,29 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
<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"
className={`relative grid size-12 shrink-0 place-items-center rounded-full text-base font-semibold ring-1 ${
pilot.isAvatar ? "text-action ring-action/60" : "text-ink-100 ring-glass-line"
}`}
>
{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">
<span className="absolute -right-1 -bottom-1 grid size-5 place-items-center rounded-full bg-space-900 text-xs font-semibold text-ink-50 ring-1 ring-glass-line">
{pilot.level}
</span>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-display text-lg font-bold tracking-wide">
<span className="truncate text-base font-semibold">
{pilot.name}
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
{pilot.isAvatar && (
<span className="ml-1 text-xs font-normal text-ink-300">(you)</span>
)}
</span>
<span className="font-display text-xs tracking-wider whitespace-nowrap text-ink-300 uppercase">
<span className="font-display text-xs whitespace-nowrap text-ink-300">
Level {pilot.level}
</span>
</div>
{pilot.isVeteran && (
<p aria-label={`${pilot.name} bio`} className="mt-0.5 text-xs text-amber-200/90 italic">
<p aria-label={`${pilot.name} bio`} className="mt-0.5 text-xs text-ink-300 italic">
{content.veteranPilot.bio}
</p>
)}
@ -145,9 +144,9 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
? `${progress.xp} / ${progress.needed} XP to next level`
: `${pilot.xp} XP · max level`}
</div>
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-black/25">
<div
className="h-full rounded-full bg-linear-to-r from-violet-500 to-fuchsia-300"
className="h-full rounded-full bg-ink-300"
style={{ width: `${progress ? (progress.xp / progress.needed) * 100 : 100}%` }}
/>
</div>
@ -170,28 +169,28 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
: "No ship"}
</div>
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
<div className="mt-3">
<div
className={`font-display text-[11px] tracking-[0.14em] uppercase ${pilot.skillPointsAvailable > 0 ? "text-amber-300" : "text-ink-400"}`}
className={`text-xs font-semibold ${pilot.skillPointsAvailable > 0 ? "text-action" : "text-ink-300"}`}
>
Skill points:{" "}
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
</div>
<ul className="mt-2 grid grid-cols-2 gap-x-3 gap-y-2">
<ul className="mt-1 divide-y divide-glass-line">
{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">
<li key={skill.id} className="flex min-h-11 items-center justify-between gap-2 py-1">
<span className="min-w-0">
<span className="block truncate text-xs text-ink-200">
<span className="block text-[13px] text-ink-100">
{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"}`}
className={`h-1 w-3 rounded-full ${i < level ? "bg-ink-200" : "bg-white/14"}`}
/>
))}
</span>
@ -202,7 +201,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
aria-label={`Raise ${skill.name} for ${pilot.name}`}
disabled={spend.isPending}
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
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"
className="grid size-11 shrink-0 place-items-center rounded-xl border border-action/60 text-action transition hover:bg-action/10 active:scale-95"
>
<Icon name="plus" className="size-4" />
</button>
@ -244,7 +243,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
if (pilot.restingInShopUntil !== null) {
return (
<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"
className="mt-3 flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-950/40 px-3 py-2 text-ink-100"
aria-label={`${pilot.name} rest`}
>
<Icon name="rest" className="size-4 shrink-0" />
@ -255,7 +254,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
if (pilot.resting && pilot.restEndsAt !== null) {
return (
<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"
className="mt-3 flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-950/40 px-3 py-2 text-ink-100"
aria-label={`${pilot.name} rest`}
>
<Icon name="rest" className="size-4 shrink-0" />
@ -268,7 +267,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
return (
<div className="mt-3">
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
<div className="flex items-baseline justify-between font-display text-xs text-ink-400">
<span>Duty</span>
<span className="tracking-normal normal-case">
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
@ -278,7 +277,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
label={`${pilot.name} duty`}
value={used}
max={pilot.dutyLimit}
tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "default"}
size="sm"
className="mt-1"
/>
@ -345,7 +344,7 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
aria-label={`Assign ${pilot.name} to`}
value={shipId}
onChange={(e) => setShipId(e.target.value)}
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"
className="min-w-0 flex-1 min-h-11 rounded-lg border border-glass-line bg-black/25 px-3 font-display text-sm focus:border-action focus:outline-none"
>
<option value="">Choose a ship…</option>
{ships.map((s) => (

View File

@ -27,7 +27,7 @@ async function shareFile(file: File): Promise<void> {
}
const codeBox =
"mt-1 h-28 w-full resize-none rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 font-mono text-xs break-all text-ink-100 focus:border-amber-400 focus:outline-none";
"mt-1 h-28 w-full resize-none rounded-lg border border-glass-line bg-black/25 px-3 py-2 font-mono text-xs break-all text-ink-100 focus:border-action focus:outline-none";
/** "Export save" (decision 0.5.92): the whole company as one code, to copy or share as a file. */
export function ExportSave() {
@ -137,7 +137,7 @@ export function ImportSave({ hasCompany }: { hasCompany: boolean }) {
{hasCompany && (
<p
role="note"
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-ink-100 ring-1 ring-amber-400/40"
>
This replaces the company on this device. Export it first if you want to keep it.
</p>
@ -187,7 +187,7 @@ export function ImportSave({ hasCompany }: { hasCompany: boolean }) {
</p>
)}
{confirming && (
<p className="text-sm text-amber-200">
<p className="text-sm text-ink-100">
{hasCompany ? "Replace your company with this save?" : "Open this company?"}
</p>
)}

View File

@ -25,7 +25,7 @@ export function Settings() {
const [howToPlay, setHowToPlay] = useState(false);
return (
<Screen className="max-w-2xl">
<Screen>
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
<Section title="Company">
@ -118,7 +118,7 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
const id = `settings-${title.toLowerCase()}`;
return (
<Panel as="section" aria-labelledby={id} className="space-y-3">
<h2 id={id} className="font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
<h2 id={id} className="font-display text-sm font-bold text-ink-300">
{title}
</h2>
{children}
@ -161,11 +161,11 @@ function Choice<T extends string>(props: {
return (
<fieldset className="flex flex-wrap items-center justify-between gap-2 text-sm">
<legend className="float-left text-ink-400">{props.label}</legend>
<div className="flex overflow-hidden rounded-lg border border-space-600">
<div className="flex overflow-hidden rounded-lg border border-glass-line">
{props.options.map(([value, text]) => (
<label
key={value}
className="cursor-pointer px-3 py-1.5 has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
className="cursor-pointer px-3 py-1.5 has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
>
<input
type="radio"

View File

@ -83,16 +83,17 @@ function HullRepair({ shipId, atHq }: { shipId: string; atHq: boolean }) {
return (
<section
aria-label="Hull"
className="mt-4 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
className="mt-4 rounded-xl border border-glass-line bg-white/5 p-3 text-sm"
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">Hull</span>
<span className="font-display font-bold">Hull</span>
<span className="tabular-nums">{Math.round(pct)}%</span>
</div>
{pct >= 100 ? (
<p className="mt-1 text-ink-400">In perfect shape.</p>
) : can ? (
<Button
variant="secondary"
size="sm"
className="mt-2"
disabled={repair.isPending || state.company.credits < cost}
@ -143,9 +144,7 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
<HullRepair shipId={ship.id} atHq={atHq} />
{/* Booked from anywhere; the work starts at the HQ (CR-027). */}
{state.tutorial === null && <ServiceSection shipId={ship.id} />}
<h3 className="mt-4 font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
Upgrades
</h3>
<h3 className="mt-4 font-display text-sm font-bold text-ink-300">Upgrades</h3>
<ul className="mt-2 space-y-2">
{content.upgrades.map((upgrade) => {
const level = ship.upgrades[upgrade.id] ?? 0;
@ -173,10 +172,10 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
<li
key={upgrade.id}
aria-label={upgrade.name}
className="rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
className="rounded-xl border border-glass-line bg-white/5 p-3 text-sm"
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">{upgrade.name}</span>
<span className="font-display font-bold">{upgrade.name}</span>
<span className="text-ink-400">
Level {level}/{max}
</span>
@ -205,7 +204,7 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
<div
role="group"
aria-label={`Start ${upgrade.name} ${check.level}?`}
className="mt-2 rounded-lg bg-space-950/70 p-2"
className="mt-2 rounded-lg bg-black/25 p-2"
>
<p className="text-ink-200">
{upgrade.name} {check.level} costs {formatCredits(check.cost)}. {ship.name}{" "}
@ -214,6 +213,7 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
</p>
<div className="mt-2 flex gap-2">
<Button
variant="secondary"
size="sm"
disabled={start.isPending}
onClick={() =>

View File

@ -22,29 +22,23 @@ export function StartScreen() {
return (
<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">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden bg-[url(/assets/map-gen/bg.webp)] bg-cover bg-center"
>
<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%]"
sprite="body-earth"
className="absolute -bottom-[82vmin] left-1/2 sm:-bottom-[96vmin] size-[120vmin] max-w-none -translate-x-1/2 drop-shadow-[0_-8px_60px_rgb(90_160_255/0.45)]"
/>
<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)]"
sprite="ship-clipper"
className="absolute bottom-[20vmin] left-1/2 size-[22vmin] max-h-32 max-w-32 translate-x-16 sm:bottom-[10vmin] sm:translate-x-40 rotate-[34deg] drop-shadow-[0_8px_14px_rgb(0_0_0/0.8)]"
/>
</div>
<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>
<h1 className="font-title text-3xl sm:text-5xl">DEADHEAD</h1>
<p className="mt-3 text-sm font-medium text-ink-200">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

View File

@ -46,10 +46,10 @@ export function TutorialBoard() {
return (
<div className="space-y-4">
{firstRun && !introDismissed && (
<Panel aria-label="Intro" feature tone="amber" className="flex items-center gap-3 text-sm">
<Panel aria-label="Intro" 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">
<p className="font-display text-base font-bold text-ink-100">
{content.tutorialCompanyName} needs a pilot.
</p>
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
@ -68,8 +68,8 @@ export function TutorialBoard() {
Company runs in the loaner {loaner.name}. You keep a share of each payout.
</p>
{runInFlight && (
<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" />
<p className="flex items-center gap-2 rounded-lg border border-glass-line bg-white/5 px-3 py-2 text-sm text-ink-100">
<Icon name="send" className="size-4 shrink-0 text-ink-300" />
Your run is in flight. Wait for it to land.
</p>
)}
@ -81,7 +81,7 @@ export function TutorialBoard() {
{board.data.contracts.length === 0 && (
<p className="text-sm text-ink-400">No company runs left this period. Check back soon.</p>
)}
<ul className="grid gap-3 sm:grid-cols-2">
<ul className="grid gap-3 @2xl:grid-cols-2">
{board.data.contracts.map((contract, index) => {
// On the first run, suggest the lightest (fastest) contract.
const suggested = firstRun && index === 0;
@ -105,14 +105,13 @@ export function TutorialBoard() {
key={contract.id}
data-suggested={suggested || undefined}
data-scripted={scripted ? true : undefined}
tone={scripted ? "fuchsia" : suggested ? "amber" : "default"}
feature={Boolean(scripted) || suggested}
tone={scripted || suggested ? "amber" : "default"}
className={`flex flex-col gap-2 ${suggested ? "shadow-glow-amber" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-display font-bold tracking-wide">
<span className="font-display font-bold">
{scripted ? (
<span className="text-fuchsia-200">{scripted.name}</span>
<span className="text-ink-300">{scripted.name}</span>
) : (
<>
{locationName(contract.originId)} → {locationName(contract.destinationId)}
@ -130,7 +129,7 @@ export function TutorialBoard() {
</span>
</div>
{scripted && (
<p className="text-xs text-fuchsia-200">
<p className="text-xs text-ink-300">
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
double pay, no questions asked
</p>
@ -140,12 +139,10 @@ export function TutorialBoard() {
destinationId={contract.destinationId}
size="sm"
/>
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2">
<div className="mt-auto flex items-end justify-between gap-2 border-t border-glass-line pt-2">
<dl className="grid flex-1 grid-cols-3 gap-2 text-sm">
<div>
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
Pay
</dt>
<dt className="font-display text-xs text-ink-400">Pay</dt>
<dd
aria-label="Pay"
className="font-display text-base font-bold text-emerald-300 tabular-nums"
@ -154,20 +151,16 @@ export function TutorialBoard() {
</dd>
</div>
<div>
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
Time
</dt>
<dt className="font-display text-xs text-ink-400">Time</dt>
<dd aria-label="Trip time" className="font-display text-base tabular-nums">
{formatDuration(tripMs)}
</dd>
</div>
<div>
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
XP
</dt>
<dt className="font-display text-xs text-ink-400">XP</dt>
<dd
aria-label="XP"
className="font-display text-base text-violet-300 tabular-nums"
className="font-display text-base text-ink-300 tabular-nums"
>
+{xp}
</dd>

View File

@ -0,0 +1,82 @@
import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema";
import { contractTripGameHours, permanentFailure } from "@deadhead/sim";
import { locationName } from "../format";
import { jobMoney, shipStatsOf } from "../game";
import { pairOptions } from "./DispatchSheet";
/** What a job row shows (decision 0.5.169). */
export interface JobFigures {
/** Payout minus fuel minus the pilot's share, as in the dispatch sheet. Null: no ship could. */
net: number | null;
/** Net per game hour of the job. */
perHour: number | null;
/** The job's game hours for the ship the figures are for. */
hours: number | null;
/** No ship can take it now: figures are for the best ship the company owns. */
estimate: boolean;
/** A ship can take it now (refuelling first counts: the sheet offers it). */
ready: boolean;
/** Why it can't go now, or what it needs first. */
note: string | null;
}
const perHourOf = (net: number, hours: number) => (hours > 0 ? Math.round(net / hours) : null);
/** The best ship's figures for a job: one that can take it now, else the best the fleet has. */
export function jobFigures(state: PlayerState, contract: Contract, now: number): JobFigures {
const options = pairOptions(state, contract, now);
const able = options.filter((o) => o.problems.length === 0 || o.shortOfFuel);
if (able.length > 0) {
const best = able.reduce((a, b) => (b.netProfit > a.netProfit ? b : a));
const ship = state.ships.find((s) => s.id === best.shipId)!;
const hours = contractTripGameHours(shipStatsOf(ship), contract, content);
return {
net: best.netProfit,
perHour: perHourOf(best.netProfit, hours),
hours,
estimate: false,
ready: true,
note: able.every((o) => o.shortOfFuel) ? "Refuel first" : null,
};
}
// A problem that time will fix says so; a ship here that could never take it isn't the job's
// problem (CR-019: the board only shows jobs the fleet can complete).
const waiting = options.find((o) => {
const ship = state.ships.find((s) => s.id === o.shipId)!;
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
if (!pilot) return true;
const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades };
return permanentFailure(contract, member, undefined, content) === null;
});
const note =
waiting?.problems[0] ??
(options.length > 0
? `Needs another ship at ${locationName(contract.originId)}`
: `No idle ship at ${locationName(contract.originId)}`);
const candidates = state.ships.flatMap((ship) => {
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
if (!pilot) return [];
const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades };
if (permanentFailure(contract, member, undefined, content) !== null) return [];
return [
{
net: jobMoney(ship, pilot, contract).netProfit,
hours: contractTripGameHours(shipStatsOf(ship), contract, content),
},
];
});
if (candidates.length === 0) {
return { net: null, perHour: null, hours: null, estimate: false, ready: false, note };
}
const best = candidates.reduce((a, b) => (b.net > a.net ? b : a));
return {
net: best.net,
perHour: perHourOf(best.net, best.hours),
hours: best.hours,
estimate: true,
ready: false,
note,
};
}

View File

@ -11,6 +11,8 @@ import type { Camera } from "../map/camera";
export type ClockFormat = "locale" | "12h" | "24h";
export type CountdownFormat = "real" | "game" | "both";
export type JobSort = "net" | "rate";
export interface Preferences {
clock: ClockFormat;
countdown: CountdownFormat;
@ -20,6 +22,8 @@ export interface Preferences {
dismissedTips: string[];
/** The map view to restore; null for the default view. */
mapCamera: Camera | null;
/** How the jobs table sorts (decision 0.5.169). */
jobSort: JobSort;
/** Local notifications on iOS and Android (decision 0.5.91). */
notifications: boolean;
}
@ -30,6 +34,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
reducedMotion: null,
dismissedTips: [],
mapCamera: null,
jobSort: "net",
notifications: true,
};
@ -67,6 +72,7 @@ export function loadPreferences(storage: PreferenceStorage | null): Preferences
dismissedTips: Array.isArray(saved.dismissedTips)
? saved.dismissedTips.filter((t): t is string => typeof t === "string")
: [],
jobSort: saved.jobSort === "rate" ? "rate" : "net",
mapCamera:
saved.mapCamera &&
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
@ -111,7 +117,7 @@ export function createPreferencesStore(
): UseBoundStore<StoreApi<PreferencesStore>> {
return create<PreferencesStore>((setState, getState) => {
const persist = () => {
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, notifications } =
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, jobSort, notifications } =
getState();
savePreferences(storage, {
clock,
@ -119,6 +125,7 @@ export function createPreferencesStore(
reducedMotion,
dismissedTips,
mapCamera,
jobSort,
notifications,
});
};

View File

@ -4,19 +4,16 @@ export type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "dan
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",
primary: "bg-action text-on-action hover:brightness-110",
secondary: "border border-glass-line bg-white/6 text-ink-50 hover:bg-white/12",
ghost: "text-ink-300 hover:bg-white/6 hover:text-ink-50",
success: "bg-earned text-space-950 hover:brightness-110",
danger: "border border-problem/60 bg-problem/12 text-problem hover:bg-problem/20",
};
const SIZES: Record<ButtonSize, string> = {
sm: "min-h-9 px-3 text-xs",
md: "min-h-11 px-4 text-sm",
sm: "min-h-11 px-3 text-sm",
md: "min-h-12 px-4 text-[15px]",
lg: "min-h-13 px-6 text-base",
};
@ -27,7 +24,7 @@ export function buttonClass(
block = false,
): string {
return [
"inline-flex items-center justify-center gap-2 rounded-lg font-display font-semibold uppercase tracking-wider",
"inline-flex items-center justify-center gap-2 rounded-xl text-center leading-tight font-semibold",
"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],

View File

@ -3,72 +3,37 @@ 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",
default: "border-glass-line bg-white/5",
muted: "border-glass-line bg-transparent",
amber: "border-action/45 bg-action/8",
cyan: "border-flight/35 bg-flight/7",
emerald: "border-earned/35 bg-earned/7",
rose: "border-problem/45 bg-problem/9",
fuchsia: "border-fuchsia-400/45 bg-fuchsia-400/8",
};
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). */
/** A card inside a glass panel (decision 0.5.165). */
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} />}
<Tag className={`relative rounded-2xl border p-4 ${TONES[tone]} ${className}`} {...rest}>
{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. */
/** A small 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>
);
return <div className={`text-xs font-semibold text-ink-400 ${className}`}>{children}</div>;
}

View File

@ -1,9 +1,9 @@
import type { ReactNode } from "react";
/** A scrolling screen's content column: centred, padded, and rising in on open. */
/** A screen's content inside its panel (decision 0.5.166): 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}`}>
<section className={`w-full animate-rise space-y-4 px-4 pt-1 pb-5 md:pt-4 ${className}`}>
{children}
</section>
);

View File

@ -27,6 +27,7 @@ export function Sheet({
subtitle,
tone = "default",
layer = "z-20",
side = false,
children,
}: {
titleId: string;
@ -36,30 +37,28 @@ export function Sheet({
tone?: Tone;
/** Stacking layer, e.g. `z-30` for a sheet over other sheets. */
layer?: string;
/** From `xl`, opens beside the wide panel and leaves it usable (decision 0.5.169). */
side?: boolean;
children: ReactNode;
}) {
return createPortal(
<div
className={`fixed inset-x-0 top-14 bottom-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
className={`fixed inset-x-0 top-14 bottom-0 ${layer} flex items-end justify-center bg-space-950/70 sm:items-center sm:p-4 ${side ? "xl:pointer-events-none xl:top-20 xl:items-start xl:justify-end xl:bg-transparent xl:pr-[calc(45rem+2rem)]" : ""}`}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`relative max-h-full 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"}`}
className={`relative max-h-full w-full max-w-md animate-sheet ${side ? "xl:pointer-events-auto xl:max-w-[26rem]" : ""} overflow-y-auto rounded-t-3xl border bg-space-850 p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-3xl ${BORDER_TONES[tone] ?? "border-glass-line"}`}
>
<div
aria-hidden="true"
className="mx-auto -mt-2 mb-3 h-1 w-10 rounded-full bg-space-600 sm:hidden"
className="mx-auto -mt-2 mb-3 h-1 w-10 rounded-full bg-white/25 sm:hidden"
/>
{eyebrow && (
<div className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase">
{eyebrow}
</div>
)}
{eyebrow && <div className="text-xs font-semibold text-ink-400">{eyebrow}</div>}
<h2
id={titleId}
className={`font-display text-xl font-bold tracking-wide ${TITLE_TONES[tone] ?? "text-ink-50"}`}
className={`font-title text-lg leading-snug ${TITLE_TONES[tone] ?? "text-ink-50"}`}
>
{title}
</h2>

View File

@ -4,12 +4,12 @@ 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",
default: "border-glass-line bg-white/6 text-ink-200",
muted: "border-glass-line bg-transparent text-ink-400",
amber: "border-action/50 bg-action/12 text-action",
cyan: "border-flight/45 bg-flight/12 text-flight",
emerald: "border-earned/45 bg-earned/12 text-earned",
rose: "border-problem/50 bg-problem/12 text-problem",
fuchsia: "border-fuchsia-400/50 bg-fuchsia-500/15 text-fuchsia-200",
};
@ -27,7 +27,7 @@ export function Pill({
}) {
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}`}
className={`inline-flex items-center gap-1 rounded-lg border px-2 py-0.5 text-xs font-semibold whitespace-nowrap ${PILL_TONES[tone]} ${className}`}
>
{icon && <Icon name={icon} className="size-3" />}
{children}
@ -36,11 +36,12 @@ export function Pill({
}
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",
amber: "bg-action",
cyan: "bg-flight",
emerald: "bg-earned",
rose: "bg-problem",
fuchsia: "bg-fuchsia-300",
default: "bg-ink-300",
};
/** A labelled `progressbar`. `label` is its accessible name. */
@ -69,10 +70,10 @@ export function Meter({
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}`}
className={`overflow-hidden rounded-full bg-white/12 ${size === "sm" ? "h-1" : "h-1.5"} ${className}`}
>
<div
className={`h-full rounded-full bg-linear-to-r transition-[width] duration-700 ${METER_FILLS[tone] ?? METER_FILLS.cyan}`}
className={`h-full rounded-full transition-[width] duration-700 ${METER_FILLS[tone] ?? METER_FILLS.cyan}`}
style={{ width: `${percent}%` }}
/>
</div>
@ -104,16 +105,14 @@ export function StatTile({
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">
<div className={`rounded-2xl border border-glass-line bg-white/5 px-3 py-2.5 ${className}`}>
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink-400">
{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"}`}
className={`mt-1 text-lg font-bold tabular-nums ${STAT_VALUE_TONES[tone] ?? "text-ink-50"}`}
>
{value}
</div>
@ -138,20 +137,10 @@ export function ScreenHeader({
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>
)}
{icon && <Icon name={icon} className="size-6 shrink-0 text-ink-300" />}
<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>
{eyebrow && <div className="text-xs font-semibold text-ink-400">{eyebrow}</div>}
<h1 className="font-title text-xl leading-tight">{title}</h1>
{subtitle && <p className="text-sm text-ink-300">{subtitle}</p>}
</div>
</div>

View File

@ -90,6 +90,14 @@ const PATHS = {
<path d="M12 2v3.5M12 18.5V22M2 12h3.5M18.5 12H22M4.9 4.9l2.5 2.5M16.6 16.6l2.5 2.5M4.9 19.1l2.5-2.5M16.6 7.4l2.5-2.5" />
</>
),
// Three dots.
more: (
<>
<circle cx="5" cy="12" r="1.2" />
<circle cx="12" cy="12" r="1.2" />
<circle cx="19" cy="12" r="1.2" />
</>
),
// A cog (outline after Lucide "settings", ISC licence).
gear: (
<>

View File

@ -53,7 +53,7 @@ describe("navigation attention badges (Step 10E.3)", () => {
],
} as Partial<PlayerState>);
// Skill points: 2 + 0 + 1 across the three pilots (decision 0.5.109).
expect(tabAttention(state)).toEqual({ "/": 2, "/contracts": 1, "/pilots": 3 });
expect(tabAttention(state)).toEqual({ "/": 2, "/contracts": 1, "/fleet": 3 });
});
it("nudges toward Jobs during the tutorial until a run is in flight", () => {
@ -67,7 +67,7 @@ describe("navigation attention badges (Step 10E.3)", () => {
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();
expect(within(nav).queryByTestId("badge-Fleet")).not.toBeInTheDocument();
});
});
@ -99,16 +99,16 @@ describe("Pilots tab badge (Step 17.5, decision 0.5.109)", () => {
});
renderApp(api, "/pilots");
const nav = await screen.findByRole("navigation", { name: "Main" });
expect(await within(nav).findByTestId("badge-Pilots")).toHaveTextContent("3");
expect(await within(nav).findByTestId("badge-Fleet")).toHaveTextContent("3");
const raise = async (pilot: string) =>
userEvent.click(
(await screen.findAllByRole("button", { name: new RegExp(`for ${pilot}$`) }))[0]!,
);
await raise(avatar.name);
await waitFor(() => expect(within(nav).getByTestId("badge-Pilots")).toHaveTextContent("2"));
await waitFor(() => expect(within(nav).getByTestId("badge-Fleet")).toHaveTextContent("2"));
await raise(avatar.name);
await raise("Rosa Vance");
await waitFor(() => expect(within(nav).queryByTestId("badge-Pilots")).not.toBeInTheDocument());
await waitFor(() => expect(within(nav).queryByTestId("badge-Fleet")).not.toBeInTheDocument());
});
});

View File

@ -250,7 +250,7 @@ describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => {
it("does not show the dialog when there is nothing to report", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

View File

@ -83,7 +83,7 @@ describe("board cards and the empty board (Step 19.4, decision 0.5.114)", () =>
}),
]);
for (const payout of ["9,001 cr", "9,002 cr", "9,003 cr"]) {
const card = (await screen.findByText(payout)).closest("li")!;
const card = (await screen.findByText(payout)).closest("tr")!;
expect(card).not.toHaveTextContent(/too heavy/i);
expect(card).not.toHaveTextContent(/too slow/i);
expect(card).not.toHaveTextContent(/skill too low/i);

View File

@ -91,7 +91,7 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
it("counts down the player's first look, then disappears", async () => {
show(owner([pilot("p1", "Ada")]), [job()]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31");
act(() => {
@ -103,13 +103,13 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
it("isn't shown without Independent pilots", async () => {
show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
it("isn't shown on a first contact, which is always the player's", async () => {
show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
});

View File

@ -61,7 +61,7 @@ describe("company settings (Step 10K.7)", () => {
await userEvent.click(confirm);
await waitFor(() => expect(api.call).toHaveBeenCalledWith("newCompany", {}));
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Jobs" })).toBeInTheDocument();
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
});
});

View File

@ -49,15 +49,16 @@ function render(contracts: Contract[], ships: Ship[]) {
);
}
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
const card = async (payout: string) => (await screen.findByText(payout)).closest("tr")!;
describe("contract card (CR-007)", () => {
it("shows the good, its class and tier, the shipper, mass and requirement", async () => {
render([job({})], [ship("courier")]);
const c = await card("3,210 cr");
expect(within(c).getByRole("heading", { name: "Medical Supplies" })).toBeInTheDocument();
expect(within(c).getByText("Medical Supplies")).toBeInTheDocument();
expect(c).toHaveTextContent("High-Value · T1");
expect(c).toHaveTextContent("Meridian Health · 6 t");
expect(c).toHaveTextContent("Meridian Health");
expect(c).toHaveTextContent("6 t");
expect(c).toHaveTextContent("Needs Expensive Cargo 1");
expect(c).not.toHaveTextContent("Priority");
});
@ -76,7 +77,7 @@ describe("contract card (CR-007)", () => {
);
const c = await card("3,210 cr");
// A Priority good can have its own name.
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
expect(within(c).getByText("Emergency Parts")).toBeInTheDocument();
// The window follows the countdown preference: real time by default (CR-011).
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
expect(c).not.toHaveTextContent("Too slow");

View File

@ -54,13 +54,11 @@ describe("Dashboard (CR-020)", () => {
activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
});
renderApp(fakeApi({ getPlayerState: () => state }));
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
expect(statValue("Ships idle")).toHaveTextContent("1");
expect(statValue("Ships in flight")).toHaveTextContent("2");
expect(screen.getByLabelText("Ships in flight")).toHaveTextContent("2 flying · 1 idle");
const jobs = screen.getByRole("region", { name: "Ships on jobs" });
const jobs = screen.getByRole("region", { name: "In flight" });
const rows = within(jobs).getAllByRole("listitem");
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
@ -135,7 +133,7 @@ describe("loan on HQ", () => {
it("is hidden once the loan is paid off", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) }));
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
});
@ -283,7 +281,7 @@ describe("your ship on the Dashboard (CR-020)", () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
const p = await panel();
expect(p).toHaveTextContent("You're not flying a ship right now.");
expect(within(p).getByRole("link", { name: "Assign yourself on Pilots" })).toHaveAttribute(
expect(within(p).getByRole("link", { name: "Assign yourself in Crew" })).toHaveAttribute(
"href",
"/pilots",
);
@ -297,9 +295,9 @@ describe("your ship on the Dashboard (CR-020)", () => {
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
});
it("has an Alerts panel, empty for now", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
const alerts = await screen.findByRole("region", { name: "Alerts" });
expect(alerts).toHaveTextContent("No alerts.");
it("says so when nothing needs the player", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
const list = await screen.findByRole("region", { name: "Needs you" });
expect(list).toHaveTextContent("Nothing right now.");
});
});

View File

@ -67,7 +67,7 @@ describe("the deal", () => {
}),
"/contracts",
);
await screen.findByRole("heading", { level: 1, name: "Contracts" });
await screen.findByRole("heading", { level: 1, name: "Jobs" });
expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument();
@ -154,7 +154,7 @@ describe("the deal", () => {
);
await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument();

View File

@ -104,7 +104,7 @@ describe("display options in the app (Step 10K.3)", () => {
}),
"/contracts?location=earth",
);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(card).toHaveTextContent("expires in 4m 16s");
act(() => usePreferences.getState().set({ countdown: "both" }));
expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h");

View File

@ -106,7 +106,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
it("shows nothing on the Bridge while awake", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(false) }), "/");
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Bridge" })).toBeInTheDocument();
expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
});
@ -115,7 +115,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
pilots: [samplePlayerState().pilots[0]!, { ...rosa, mode: "manual" }],
});
renderApp(fakeApi({ getPlayerState: () => manual }), "/");
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Bridge" })).toBeInTheDocument();
expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
});

View File

@ -40,7 +40,7 @@ function render(boards: Contract[][], periodMs = 600_000) {
const remember = (ids: string[]) =>
localStorage.setItem("deadhead:seen-board:earth", JSON.stringify(ids));
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
const card = async (payout: string) => (await screen.findByText(payout)).closest("tr")!;
describe("visible board refresh (CR-008)", () => {
it("tags contracts that weren't on the last board seen here, and says so", async () => {

View File

@ -66,7 +66,7 @@ describe("goals on HQ", () => {
it("is not shown during the tutorial", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
expect(screen.queryByRole("region", { name: "Goals" })).not.toBeInTheDocument();
});
});

View File

@ -1,7 +1,7 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, Ship } from "@deadhead/schema";
import { contractTripGameHours, effectiveStats } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatGameHours } from "../src/format";
@ -84,9 +84,10 @@ describe("job length on the board", () => {
);
const hours = formatGameHours(hoursFor("courier", contract));
const time = await screen.findByLabelText(`Takes ${hours} of game time`);
const line = time.parentElement!;
expect(line).toHaveTextContent(`Pacific Orbital Logistics · 12 t · 0.38M km · ${hours}`);
expect(within(line).getByText(/12 t/)).toBeInTheDocument();
const row = time.closest("tr")!;
expect(row).toHaveTextContent("Pacific Orbital Logistics");
expect(row).toHaveTextContent("0.38M km");
expect(time.parentElement).toHaveTextContent(`12 t · ${hours}`);
});
it("times the job for the fastest ship that could take it, preferring one at the origin", () => {

View File

@ -0,0 +1,126 @@
import type { Contract, Ship } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { usePreferences } from "../src/state/preferences";
import { pairOptions } from "../src/screens/DispatchSheet";
import { jobFigures } from "../src/screens/jobFigures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.now();
const job = (overrides: Partial<Contract>): Contract =>
({
id: "j1",
locationId: "earth",
originId: "earth",
destinationId: "moon",
goodId: "consumer-goods",
cargoTypeId: "standard",
massTonnes: 10,
payout: 2000,
shipper: "Pacific Orbital Logistics",
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
priority: false,
deliveryWindowGameHours: null,
tier: null,
requiredSkill: null,
requiredLongHaulLevel: 0,
firstContact: false,
...overrides,
}) as Contract;
const ship = (id: string, overrides: Partial<Ship> = {}): Ship => ({
id,
modelId: "courier",
name: id,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar",
...overrides,
});
function owner(ships: Ship[]) {
const base = samplePlayerState();
return samplePlayerState({
tutorial: null,
bays: [{ id: "b", locationId: "earth" }],
ships,
pilots: [{ ...base.pilots[0]!, assignedShipId: ships[0]?.id ?? null }],
});
}
function show(contracts: Contract[], ships: Ship[]) {
renderApp(
fakeApi({
getPlayerState: () => owner(ships),
getContractBoard: () => ({ contracts, periodEndsAt: NOW + 3_600_000 }),
}),
"/contracts?location=earth",
);
}
const rowNames = () =>
screen
.getAllByRole("row")
.map((r) => r.getAttribute("aria-label"))
.filter(Boolean);
describe("jobs table (decision 0.5.169)", () => {
beforeEach(() => usePreferences.setState({ jobSort: "net" }));
it("shows the same net as the dispatch sheet", () => {
const state = owner([ship("s1")]);
const contract = job({});
const figures = jobFigures(state, contract, NOW);
expect(figures.ready).toBe(true);
expect(figures.estimate).toBe(false);
expect(figures.net).toBe(pairOptions(state, contract, NOW)[0]!.netProfit);
expect(figures.perHour).toBe(Math.round(figures.net! / figures.hours!));
});
it("sorts by net, then by credits per hour, and remembers the choice", async () => {
const big = job({
id: "big",
goodId: "packaged-food",
payout: 3000,
destinationId: "moon",
massTonnes: 18,
});
const small = job({ id: "small", goodId: "mail", payout: 2000, massTonnes: 1 });
show([small, big], [ship("s1")]);
await screen.findByRole("table");
expect(rowNames()).toEqual(["Packaged Food", "Mail & Parcels"]);
const net = screen.getByRole("columnheader", { name: /Net/ });
expect(net).toHaveAttribute("aria-sort", "descending");
await userEvent.click(within(screen.getByRole("table")).getByRole("button", { name: /cr\/h/ }));
expect(screen.getByRole("columnheader", { name: /cr\/h/ })).toHaveAttribute(
"aria-sort",
"descending",
);
expect(net).toHaveAttribute("aria-sort", "none");
expect(usePreferences.getState().jobSort).toBe("rate");
});
it("keeps jobs it can't take below, with the reason and an estimate", async () => {
// The only ship is away: the figures are an estimate, and the row says why.
const away = ship("s1", { status: "in-flight", locationId: null, dispatchId: "d" });
show([job({})], [away]);
await screen.findByRole("table");
expect(screen.getByText("Can't take yet")).toBeInTheDocument();
const row = screen.getByRole("row", { name: "Consumer Goods" });
expect(row).toHaveTextContent("No idle ship at Earth");
expect(within(row).getByLabelText("Net, estimated")).toHaveTextContent(/^≈/);
});
it("opens the job from its row", async () => {
show([job({})], [ship("s1")]);
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
});
});

View File

@ -39,14 +39,48 @@ describe("app layout", () => {
);
});
it("opens on the Bridge tab, the Dashboard, with HQs among eight tabs (CR-020)", async () => {
it("opens on the Bridge among four tabs (CR-029)", async () => {
renderApp(signedIn());
const nav = await screen.findByRole("navigation", { name: "Main" });
const links = within(nav).getAllByRole("link");
expect(links).toHaveLength(8);
expect(links[0]).toHaveAccessibleName("Bridge");
["Bridge", "Jobs", "Fleet", "More"].forEach((name, i) =>
expect(links[i]).toHaveAccessibleName(name),
);
expect(links[0]).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect(within(nav).getByRole("link", { name: "HQs" })).toHaveAttribute("href", "/hqs");
expect(screen.getByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument();
});
it.each([
["/map", "Map", "Bridge"],
["/pilots", "Crew", "Fleet"],
["/market", "Shipyard", "More"],
["/locations", "Places", "More"],
["/hqs", "HQs", "More"],
["/logbook", "Logbook", "More"],
["/settings", "Settings", "More"],
])("still opens %s, as %s under %s", async (path, title, tab) => {
renderApp(signedIn(), path);
expect(await screen.findByRole("heading", { level: 1, name: title })).toBeInTheDocument();
const nav = screen.getByRole("navigation", { name: "Main" });
expect(within(nav).getByRole("link", { name: tab })).toHaveAttribute("aria-current", "page");
});
it("switches between Ships and Crew inside Fleet", async () => {
renderApp(signedIn(), "/fleet");
const views = await screen.findByRole("navigation", { name: "Fleet views" });
await userEvent.click(within(views).getByRole("link", { name: /^Crew/ }));
expect(await screen.findByRole("heading", { level: 1, name: "Crew" })).toBeInTheDocument();
});
it("More reaches every screen without a tab", async () => {
renderApp(signedIn(), "/more");
await screen.findByRole("heading", { level: 1, name: "More" });
for (const [name, href] of [
[/^Shipyard/, "/market"],
[/^Places & HQs/, "/locations"],
[/^Logbook/, "/logbook"],
] as const) {
expect(screen.getByRole("link", { name })).toHaveAttribute("href", href);
}
});
});

View File

@ -156,21 +156,18 @@ describe("location contract boards", () => {
renderApp(boardApi(), "/contracts?location=earth");
await screen.findByText("1,999 cr");
const items = screen.getAllByRole("listitem");
const [plain, special] = items;
// CR-007 card: good headline, class chip, shipper · mass · distance.
expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument();
expect(plain).toHaveTextContent("Standard");
expect(plain).toHaveTextContent("Pacific Orbital Logistics · 50 t");
expect(plain).toHaveTextContent("No skills needed");
const plain = screen.getByRole("row", { name: "Consumer Goods" });
const special = screen.getByRole("row", { name: "Machine Parts" });
// CR-007: good, class chip, shipper, mass and distance.
expect(plain).toHaveTextContent("Pacific Orbital Logistics");
expect(plain).toHaveTextContent("50 t");
expect(plain).toHaveTextContent("expires in 30m 00s");
expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
expect(within(plain).queryByText("First contact")).not.toBeInTheDocument();
expect(special).toHaveTextContent("Earth → Mars");
expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument();
expect(special).toHaveTextContent("To Mars");
expect(special).toHaveTextContent("Heavy · T2");
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
expect(special).toHaveTextContent(/M km/);
expect(within(special!).getByText("First contact")).toBeInTheDocument();
expect(within(special).getByText("First contact")).toBeInTheDocument();
});
});

View File

@ -260,8 +260,7 @@ describe("the Logbook screen (Step 25.4, decision 0.5.141)", () => {
it("is reachable from the Bridge", async () => {
renderApp(app(), "/");
const card = await screen.findByLabelText("Logbook");
await userEvent.click(within(card).getByRole("link", { name: "Open the Logbook" }));
await userEvent.click(await screen.findByRole("link", { name: "Open the Logbook" }));
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
});
});

View File

@ -9,10 +9,14 @@ import { buildScene } from "../src/map/scene";
export function MapCanvas({
state,
onSelect,
focused = true,
}: {
state: PlayerState;
onSelect?: (selection: MapSelection | null) => void;
focused?: boolean;
}) {
// Behind a panel, the screen's own controls are what tests look for.
if (!focused) return <div data-testid="map-canvas" />;
const scene = buildScene(state, Date.now());
return (
<div data-testid="map-canvas">

View File

@ -0,0 +1,71 @@
import type { PlayerState, RadioCall, Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { needsYou } from "../src/components/NeedsYou";
import { tabAttention } from "../src/layout/attention";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const base = samplePlayerState();
const avatar = base.pilots[0]!;
const ship: Ship = {
id: "s1",
modelId: "courier",
name: "Restless Sparrow",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar",
};
const owner = (overrides: Partial<PlayerState> = {}): PlayerState =>
samplePlayerState({
tutorial: null,
ships: [ship],
bays: [{ id: "b", locationId: "earth" }],
pilots: [{ ...avatar, assignedShipId: "s1", skillPointsAvailable: 2 }],
goals: [{ id: "buy-a-ship", status: "ready" }],
...overrides,
} as Partial<PlayerState>);
const call = { dispatchId: "d1", eventId: "debris-field", pilotId: avatar.id } as RadioCall;
describe("Needs you (decision 0.5.168)", () => {
it("orders radio calls, ready ships, skill points, then goals", () => {
const items = needsYou(owner({ radioCalls: [{ ...call, windowEndsAt: 1 }] }));
expect(items.map((i) => i.kind)).toEqual(["radio", "ready", "skills", "goal"]);
});
it("badges count what the list holds, tab by tab", () => {
const state = owner({ radioCalls: [{ ...call, windowEndsAt: 1 }] });
const items = needsYou(state);
const badges = tabAttention(state);
expect(badges).toEqual({ "/": 2, "/contracts": 1, "/fleet": 2 });
const total = items.reduce((sum, i) => sum + i.count, 0);
expect(Object.values(badges).reduce((sum, n) => sum + n, 0)).toBe(total);
});
it("names who has skill points and opens Crew", async () => {
renderApp(fakeApi({ getPlayerState: () => owner() }));
const list = await screen.findByRole("region", { name: "Needs you" });
await userEvent.click(within(list).getByRole("link", { name: /2 skill points to spend/ }));
expect(await screen.findByRole("heading", { level: 1, name: "Crew" })).toBeInTheDocument();
});
it("sends a ready ship to the jobs where it is", async () => {
renderApp(
fakeApi({
getPlayerState: () => owner(),
getContractBoard: () => ({ contracts: [], periodEndsAt: Date.now() + 60_000 }) as never,
}),
);
const list = await screen.findByRole("region", { name: "Needs you" });
expect(within(list).getByRole("link", { name: /Restless Sparrow is ready/ })).toHaveAttribute(
"href",
"/contracts?location=earth",
);
});
});

View File

@ -24,7 +24,7 @@ describe("guest onboarding", () => {
expect(api.call).toHaveBeenCalledWith("createGuest", {});
// Lands on Contracts with the intro and the light contract suggested.
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Jobs" })).toBeInTheDocument();
expect(await screen.findByLabelText("Intro")).toHaveTextContent(
"Halvorsen Lunar Freight needs a pilot.",
);
@ -44,7 +44,7 @@ describe("guest onboarding", () => {
it("skips the start screen when a stored session is still valid", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api);
expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything());
});

View File

@ -0,0 +1,44 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { DEFAULT_CAMERA, framed, useMapFrame } from "../src/map/camera";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const app = () => fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) });
describe("the shell (decision 0.5.166)", () => {
it("steps the panel through its heights by tap or keyboard, never needing a drag", async () => {
renderApp(app());
const handle = await screen.findByRole("button", { name: /Panel height: half/ });
await userEvent.click(handle);
expect(handle).toHaveAccessibleName(/Panel height: full/);
await userEvent.click(handle);
expect(handle).toHaveAccessibleName(/Panel height: peek/);
await userEvent.click(handle);
expect(handle).toHaveAccessibleName(/Panel height: half/);
});
it("opens tall screens tall", async () => {
renderApp(app(), "/settings");
expect(await screen.findByRole("button", { name: /Panel height: full/ })).toBeInTheDocument();
});
it("keeps the map mounted across screens", async () => {
renderApp(app());
const canvas = await screen.findByTestId("map-canvas");
await userEvent.click(screen.getByRole("link", { name: "More" }));
await screen.findByRole("heading", { level: 1, name: "More" });
expect(screen.getByTestId("map-canvas")).toBe(canvas);
});
it("shifts the view by the frame without touching the saved camera", () => {
useMapFrame.setState({ offsetX: 10, offsetY: -20 });
expect(framed(DEFAULT_CAMERA, useMapFrame.getState())).toEqual({
...DEFAULT_CAMERA,
panX: 10,
panY: -20,
});
expect(DEFAULT_CAMERA.panX).toBe(0);
useMapFrame.setState({ offsetX: 0, offsetY: 0 });
});
});

View File

@ -46,7 +46,7 @@ describe("player state store", () => {
getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }),
});
renderApp(api);
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
expect(stateCalls(api)).toBe(1);
act(() => vi.advanceTimersByTime(29_000));
@ -67,7 +67,7 @@ describe("player state store on focus", () => {
it("refetches when the app regains focus", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api);
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
const before = stateCalls(api);
// Leaving and returning to the app, as the browser reports it to TanStack Query.

View File

@ -132,7 +132,7 @@ describe("radio calls (Step 26.6, decision 0.5.144)", () => {
await userEvent.click(within(sheet).getByRole("button", { name: "Decide later" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const panel = screen.getByLabelText("Radio calls");
expect(panel).toHaveTextContent("Radio calls (1)");
expect(panel).toHaveTextContent("Debris field ahead");
await userEvent.click(within(panel).getByRole("button", { name: "Answer" }));
expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument();
});

View File

@ -70,7 +70,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Shake on it" }));
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
// The deal is done, but the permission prompt is still open.
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
@ -85,7 +85,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
it("Go to Pilots opens the Pilots tab and the pop-up doesn't come back", async () => {
renderApp(fakeApi({ getPlayerState: () => afterDeal() }), "/");
await userEvent.click(await screen.findByRole("button", { name: "Go to Pilots" }));
await screen.findByRole("heading", { level: 1, name: "Pilots" });
await screen.findByRole("heading", { level: 1, name: "Crew" });
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
});
@ -100,7 +100,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(),
);
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
act(() => usePreferences.getState().showTipsAgain());
@ -109,7 +109,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
it("doesn't appear with no unspent points", async () => {
renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 0) }), "/");
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
await screen.findByRole("heading", { level: 1, name: "Bridge" });
// Give the prompt check a turn to settle.
await act(async () => {});
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();

View File

@ -86,13 +86,13 @@ describe("empty states", () => {
it("HQ: no ships points to the Market", async () => {
show("/");
expect(await screen.findByText("No ships yet — buy one in the Market.")).toBeInTheDocument();
expect(await screen.findByText("No ships yet — buy one in the Shipyard.")).toBeInTheDocument();
expect(screen.getByText("Nothing in flight.")).toBeInTheDocument();
});
it("Fleet: no ships links to the Market", async () => {
show("/fleet");
expect(await screen.findByRole("link", { name: "visit the Market" })).toHaveAttribute(
expect(await screen.findByRole("link", { name: "visit the Shipyard" })).toHaveAttribute(
"href",
"/market",
);

View File

@ -3,21 +3,86 @@ 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");
const here = dirname(fileURLToPath(import.meta.url));
const css = readFileSync(join(here, "../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"/);
type Rgb = readonly [number, number, number];
/** A `--color-…` token from the theme as RGB, with its alpha if it has one. */
function token(name: string): { rgb: Rgb; alpha: number } {
const value = new RegExp(`--color-${name}: ([^;]+);`).exec(css)?.[1];
if (!value) throw new Error(`no token --color-${name}`);
if (value.startsWith("#")) {
const n = parseInt(value.slice(1), 16);
return { rgb: [(n >> 16) & 255, (n >> 8) & 255, n & 255], alpha: 1 };
}
const [r, g, b, a] = value.match(/[\d.]+/g)!.map(Number);
return { rgb: [r!, g!, b!], alpha: a ?? 1 };
}
const over = (top: Rgb, alpha: number, under: Rgb): Rgb =>
[0, 1, 2].map((i) => top[i]! * alpha + under[i]! * (1 - alpha)) as unknown as Rgb;
function luminance(rgb: Rgb): number {
const [r, g, b] = rgb.map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
}
function contrast(a: Rgb, b: Rgb): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi! + 0.05) / (lo! + 0.05);
}
const TEXT = [
"ink-50",
"ink-100",
"ink-200",
"ink-300",
"ink-400",
"ink-500",
"action",
"flight",
"earned",
"problem",
];
describe("theme (CR-029)", () => {
it("self-hosts the title and body fonts", () => {
expect(css).toMatch(/@import "@fontsource\/michroma\//);
expect(css).toMatch(/@import "@fontsource\/instrument-sans\//);
expect(css).toMatch(/--font-title: "Michroma"/);
expect(css).toMatch(/--font-sans: "Instrument Sans"/);
});
it("puts every screen on the space backdrop", () => {
expect(css).toContain('url("/assets/map-gen/bg.webp")');
it("keeps the wide title face off labels and numbers", () => {
expect(css).toMatch(/--font-display: "Instrument Sans"/);
});
it("uses the plain ground behind screens, not a backdrop image", () => {
expect(css).not.toContain("bg.webp");
expect(css).toContain("background-color: var(--color-space-950)");
});
it("turns motion off under prefers-reduced-motion", () => {
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
});
// A panel can sit over the Sun or a bright star, so white is the worst case.
it.each(TEXT)("%s reads at 4.5:1 on the glass over dark space and over white", (name) => {
const glass = token("glass");
const text = token(name).rgb;
for (const behind of [token("space-950").rgb, [255, 255, 255] as Rgb]) {
expect(contrast(text, over(glass.rgb, glass.alpha, behind))).toBeGreaterThanOrEqual(4.5);
}
});
it.each([
["on-action", "action"],
["on-problem", "problem"],
])("%s reads at 4.5:1 on %s", (text, fill) => {
expect(contrast(token(text).rgb, token(fill).rgb)).toBeGreaterThanOrEqual(4.5);
});
});

View File

@ -52,7 +52,7 @@ describe("welcome cards (Step 22.2, decision 0.5.131)", () => {
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(api.call).toHaveBeenCalledWith("markWelcomeSeen", {});
expect(state().welcomeSeen).toBe(true);
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Jobs" })).toBeInTheDocument();
expect(await screen.findByLabelText("Intro")).toHaveTextContent("needs a pilot");
});

View File

@ -8,7 +8,7 @@ import { defineConfig, type Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
/** --color-space-900: the app's background, used for the splash and browser chrome. */
const THEME_COLOR = "#080d1d";
const THEME_COLOR = "#050919";
/**
* Installable PWA (Step 11.1): manifest, and a service worker that precaches the

View File

@ -91,3 +91,5 @@ Record these in `DECISIONS.md` (as plan §0.5.47–0.5.51).
- **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.
- **Amended by CR-029:** The Deep Field theme and four-tab navigation replace this document's look and eight tabs.

View File

@ -46,3 +46,5 @@ Record as plan §0.5.118–0.5.121.
### Step 20.4 — Regression and review
- Plan amendments (Steps 8.1 and 10E.4), full suite, coverage, lint, build, screenshots of Dashboard and HQs at desktop and phone width. Stop for review.
- **Amended by CR-029:** The Bridge leads with the player's ship and a Needs you list; the Alerts panel and stat tiles are gone, and the map sits behind it.

View File

@ -104,3 +104,5 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the nex
## 7. Out of scope
A notification history panel beyond the Logbook, per-type toast settings, sounds, browser (system) notifications on web, and anything else in `implementation-plan.md` §0.3.
- **Amended by CR-029:** Toasts follow the new theme; their rules are unchanged.

View File

@ -0,0 +1,149 @@
# Change Request CR-029 — Deep Field UI Rework
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phase 13 and CR-001 through CR-028 complete
**Do this:** as **Phase 29**
**Status:** built (Phase 29); see the notes in `DECISIONS.md` for where the build differs from this plan
**Related docs:** `CR-005-ui-overhaul.md`, `CR-020-bridge-dashboard.md`, `CR-028-live-toasts.md`, `DECISIONS.md`
**Design reference:** the Deep Field canvas, https://claude.ai/artifact/LprS2DkuTSf2PfJm7VUSe4 (phone prototype, all 15 phone screens, 15 desktop views)
This change is client-only. It changes no game rules, engine commands or save data, so it needs no save migration.
---
## 1. Why
Every screen today is the same neon-edged card over a starfield, amber marks almost everything, and desktop uses a narrow center column. Key decisions are buried: job cards show gross pay, and net profit only appears in the dispatch sheet. Badges count things without saying what they are, and four tabs don't match their screen titles.
The user chose the **Deep Field** direction with **Switchyard's jobs table**, for a game played mostly on phones.
## 2. Rules
### The shell
- The solar system map is always behind the UI, and it is the Bridge. There is one map canvas for the whole session.
- Screens are glass panels over the map: a bottom sheet on phones, a right-hand column from 768 px up.
- Bottom tabs on phones, a floating left rail on desktop: **Bridge, Jobs, Fleet, More**.
- Fleet has two views, **Ships** and **Crew**. More holds **Shipyard, Places & HQs, Logbook, Dispatch Office, While you were away** and **Settings**.
- A screen's tab and its title use the same name.
### Look
| Item | Rule |
| ---- | ---- |
| Color | Navy ground; amber only for the main action and the player's own ship; blue for other flights; mint for money earned; coral for problems. |
| Type | Michroma for the wordmark and screen titles only; Instrument Sans for everything else, with tabular numerals. |
| Glass | At least 82% opaque, so text passes contrast with the Sun or a bright star behind it. |
| Art | Large on Bridge, ship detail, Shipyard and Start; small or absent in lists. |
| Removed | Corner brackets, glows on ordinary controls, amber on full gauges. |
### Needs you
The Bridge leads with one list of what needs the player, in this order: radio calls, the player's idle ship with its best job, skill points, goals ready to claim. Tab badges count the same list. The empty Alerts panel goes away.
### Jobs table
- Columns on phones: job, net, credits per hour. From 768 px, also shipper, weight and hours.
- Sorts by net (the default) or credits per hour; the choice is remembered on the device.
- Jobs the player can take now come first. Jobs they can't take sit under **Can't take yet**, each with its reason.
- **Net** keeps the dispatch sheet's definition: payout minus fuel minus the pilot's share, excluding the lender's cut.
- **Credits per hour** is net divided by the job's game hours.
- The figures use the best ship that could take the job now. A job with no such ship uses the best ship the company owns, marked as an estimate.
### Dispatch
The sheet leads with what the player clears. Payout and fuel sit beside it, ships to choose from follow, and timing facts take one line. On desktop it opens in the side panel and the table stays visible.
### Accessibility
- Text meets 4.5:1 and tap targets are at least 44 px. The Deep Field colors were measured against the worst-case background; they need re-measuring in the app.
- The jobs table is a real `<table>` with `aria-sort`.
- Reduced motion stops ships moving along routes and the sheet's animation. Ships still show at their true position.
## 3. Rules for this change
- Every step leaves the game playable and the suite green.
- Every feature stays. The mockups don't draw all of them: see §6.
- Existing URLs keep working, redirecting where a screen has moved.
- Keep the accessible names tests rely on unless this document renames them. About 29 test and e2e files mention the old screen names, and about 41 source files use the old color classes.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5 as **0.5.165–0.5.169**.
- **0.5.165 Deep Field theme,** per §2 Look; replaces the "freight terminal HUD" (0.5.47–0.5.48).
- **0.5.166 Map as the shell.** One canvas behind every screen; the Bridge and Map merge. It pauses while the tab is hidden or a panel fully covers it.
- **0.5.167 Four tabs,** per §2 The shell; replaces the eight tabs of CR-005 and the "Bridge" label split of 0.5.118.
- **0.5.168 Needs you,** per §2; replaces the Alerts panel of 0.5.120 and redefines the badges of 0.5.49.
- **0.5.169 Jobs table and credits per hour,** per §2.
## 5. Steps — Phase 29
### Step 29.1 — Theme tokens and type
- Add the fonts; retune the `@theme` tokens in `index.css`; replace the starfield body background with the Deep Field ground; add semantic tokens for action, own ship, flight, earned and problem.
- **Test:** The suite stays green with no markup changes. Measured contrast passes for each text color on the glass.
### Step 29.2 — UI kit
- Restyle `Button`, `Panel`, `Sheet`, `Pill`, `Meter`, `StatTile` and `Eyebrow`; remove brackets and glows; set the 44 px minimums. Move literal `amber-*` and `cyan-*` classes in screens to the new tokens.
- Apply `font-title` to the wordmark and screen titles.
- Fix the phone text wraps the wider body font caused in Step 29.1: "Find work here" on the Bridge and "When you send them" on Pilots.
- **Test:** `ui-kit.test.tsx` updated. Screenshots of all screens at 390 and 1280 px.
### Step 29.3 — The map shell
- Lift `MapCanvas` from `MapScreen` into `Layout`, so it survives route changes. Add the panel host: a bottom sheet with drag detents on phones, a right column on desktop. Settle how dragging the sheet and panning the map share gestures.
- Keep tap-to-select, zoom, Reset view and the location and flight panels.
- **Test:** Navigating doesn't recreate the canvas. The canvas pauses when hidden. Frame rate and battery checked on a real mid-range phone, with the blur reduced or dropped if it can't hold up.
### Step 29.4 — Navigation
- Move to four tabs and the More screen; add Fleet's Ships and Crew views; rename titles to match tabs; add redirects from `/pilots`, `/hqs`, `/market`, `/locations` and `/map`.
- **Test:** `layout.test.tsx` and affected tests and e2e helpers updated. Each old URL lands on the right screen.
### Step 29.5 — Bridge
- Build the Needs you list and move badge counts onto it; add the next-move card; list flights; keep Fleet Asleep, loan, goals, Dispatch Office and the tutorial panel.
- **Test:** Each kind of item appears, opens the right place, and leaves when done. The badge equals the list's count.
### Step 29.6 — Jobs table
- Extract net and fuel from `pairOptions` into a shared function, so the table and sheet can't disagree. Build the table, sorting and the Can't take yet group; keep New, Priority, First contact, Yours for and board refresh.
- **Test:** Table and sheet show the same net. Sorting works and is remembered. Each blocked reason shows. Column sets match each width.
### Step 29.7 — Dispatch, Fleet and Crew
- Restyle the dispatch and deadhead sheets; build ship detail, including fuel, service bookings and upgrades; rebuild pilot cards with skills, duty, rest and independent controls.
- **Test:** Existing dispatch, fleet, service and pilot tests pass with updated selectors.
### Step 29.8 — Everything else
- Shipyard, Places & HQs, Logbook, Settings and its sub-screens, Start, Welcome, the deal, tutorial board, skill-point prompt, radio calls, While you were away, toasts, and developer tools.
- **Test:** Existing tests pass with updated selectors. Screenshots of every screen and state at 390 and 1280 px.
### Step 29.9 — Regression
- Run the full suite, type check and e2e. Audit keyboard use, screen reader labels and contrast in the built app.
- **Test:** CI is green. Manual check on a phone and on desktop through a full game: tutorial, deal, first ship, and a radio call.
## 6. Not yet designed
The canvas shows a mid-game company. These need designing during their steps, and the user reviews them there: the tutorial and deal, Welcome cards, Fleet Asleep, loan payment, service bookings, refuel flows, the deadhead sheet, independent pilot controls, empty and error states, and a large fleet (20 or more ships and long job boards).
## 7. Open questions
- **Shipyard placement:** the canvas puts it under More, but buying ships is a main goal. Reconsider if it feels buried in play.
- **Estimated figures:** whether a job with no available ship should show net at all, or only the reason it's blocked.
## 8. Amendments
| Where | Change |
| ----- | ------ |
| CR-005 | Theme and eight-tab navigation replaced by this document. |
| CR-020 | The Bridge's layout and Alerts panel replaced; its content moves per Step 29.5. |
| CR-028 | Toast styling follows the new theme; toast rules unchanged. |
## 9. Out of scope
Game rules and balance, new art, a light theme, and moving away from the Pixi map.

Some files were not shown because too many files have changed in this diff Show More