Step 29.8: Shipyard, Places, Start, Welcome and the remaining screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-11-04 18:41:02 -05:00
parent 4a33aed970
commit 613b06a83d
21 changed files with 81 additions and 60 deletions

View File

@ -352,7 +352,7 @@ function Totals({ summary }: { summary: Summary }) {
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}>

View File

@ -4,13 +4,35 @@ import { useLoadedPlayer } from "../state/actions";
/** Fleet's two views, Ships and Crew (decision 0.5.167). */
export function FleetViews() {
const state = useLoadedPlayer();
const views = [
{ to: "/fleet", label: `Ships ${state.ships.length}` },
{ to: "/pilots", label: `Crew ${state.pilots.length}` },
];
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="Fleet views"
aria-label={label}
className="flex gap-0.5 rounded-xl border border-glass-line bg-black/25 p-0.5"
>
{views.map((view) => (

View File

@ -124,7 +124,7 @@ 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":
@ -202,7 +202,7 @@ function PilotSection({ log, state, open }: { log: PilotLog; state: PlayerState;
<details open={open} className="rounded-xl border border-glass-line bg-black/25 p-3">
<summary className="flex cursor-pointer items-baseline justify-between gap-2">
<span className="font-display font-bold">{log.name}</span>
{log.xp > 0 && <span className="text-xs text-violet-300">+{log.xp} XP</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-ink-100">
@ -238,7 +238,7 @@ 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>
@ -249,7 +249,7 @@ export function PilotLogbook({ logbook, state }: { logbook: Logbook; state: Play
<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>
@ -285,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-white/5 px-4 py-1.5 font-display text-sm text-ink-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

@ -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-ink-100" : "border-cyan-400/40 bg-cyan-950/30 text-ink-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>

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",
};
/**

View File

@ -85,13 +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-white/5 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-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 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>
@ -100,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

@ -126,7 +126,7 @@ export function PanelHost({ children }: { children: ReactNode }) {
"--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-glass shadow-panel 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 ${
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" : ""
}`}
>

View File

@ -138,7 +138,7 @@ function ShipRow(props: {
? `${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>

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";
@ -22,6 +23,7 @@ export function HQs() {
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>
@ -58,7 +60,7 @@ function HqCard({ bay }: { bay: Bay }) {
);
return (
<Panel as="li" 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

View File

@ -96,7 +96,7 @@ export function JobsTable({
New
</Pill>
)}
<ClassChip contract={contract} />
{contract.cargoTypeId !== "standard" && <ClassChip contract={contract} />}
{contract.priority && contract.deliveryWindowGameHours !== null && (
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
)}
@ -132,7 +132,7 @@ export function JobsTable({
<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}` : "No skills needed"} · expires in{" "}
{requirements && `Needs ${requirements} · `}expires in{" "}
<Countdown to={contract.expiresAt} done="expired" />
</div>
</td>

View File

@ -175,7 +175,7 @@ function LocationBoard({
{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 text-ink-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>
@ -278,7 +278,6 @@ 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-ink-100">

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";
@ -27,7 +28,8 @@ export function Locations() {
return (
<Screen>
<ScreenHeader title="Places" eyebrow="Known space" icon="places" />
<ScreenHeader title="Places" icon="places" />
<PlacesViews />
{lease.isError && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(lease.error)}

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">

View File

@ -30,7 +30,6 @@ export function Market() {
<Screen>
<ScreenHeader
title="Shipyard"
eyebrow="Shipyard"
icon="market"
subtitle="New ships are delivered with no pilot to one of your HQs."
/>
@ -53,17 +52,16 @@ export function Market() {
const affordable = state.company.credits >= model.price;
return (
<Panel as="li" key={model.id} 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" />
<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">{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)}
@ -133,7 +132,7 @@ function Spec(props: { icon: IconName; label: string; value: string; share?: num
{props.share !== undefined && (
<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-cyan-500 to-sky-300"
className="h-full rounded-full bg-ink-300"
style={{ width: `${Math.max(4, props.share * 100)}%` }}
/>
</div>

View File

@ -3,8 +3,12 @@ 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", detail: "Everywhere you can fly", icon: "places" },
{ to: "/hqs", title: "HQs", detail: "Your bases and who's there", icon: "hq" },
{
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" },
];

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">

View File

@ -22,27 +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-title text-3xl sm:text-5xl 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 text-ink-300">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

@ -68,7 +68,7 @@ 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-ink-100">
<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>
@ -105,13 +105,13 @@ export function TutorialBoard() {
key={contract.id}
data-suggested={suggested || undefined}
data-scripted={scripted ? true : undefined}
tone={scripted ? "fuchsia" : suggested ? "amber" : "default"}
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">
{scripted ? (
<span className="text-fuchsia-200">{scripted.name}</span>
<span className="text-ink-300">{scripted.name}</span>
) : (
<>
{locationName(contract.originId)} → {locationName(contract.destinationId)}
@ -129,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>
@ -160,7 +160,7 @@ export function TutorialBoard() {
<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

@ -43,7 +43,7 @@ export function Sheet({
}) {
return createPortal(
<div
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-4 xl:items-start xl:justify-end xl:bg-transparent xl:pr-[calc(45rem+2rem)]" : ""}`}
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"

View File

@ -77,8 +77,7 @@ describe("app layout", () => {
await screen.findByRole("heading", { level: 1, name: "More" });
for (const [name, href] of [
[/^Shipyard/, "/market"],
[/^Places/, "/locations"],
[/^HQs/, "/hqs"],
[/^Places & HQs/, "/locations"],
[/^Logbook/, "/logbook"],
] as const) {
expect(screen.getByRole("link", { name })).toHaveAttribute("href", href);

View File

@ -159,10 +159,8 @@ describe("location contract boards", () => {
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("Standard");
expect(plain).toHaveTextContent("Pacific Orbital Logistics");
expect(plain).toHaveTextContent("50 t");
expect(plain).toHaveTextContent("No skills needed");
expect(plain).toHaveTextContent("expires in 30m 00s");
expect(within(plain).queryByText("First contact")).not.toBeInTheDocument();