Step 10E.8: locations, map panels, title screen and dialogs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:51:00 -04:00
parent 45a21a8684
commit c11d52f96f
8 changed files with 304 additions and 177 deletions

View File

@ -24,7 +24,7 @@ const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ defaul
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = { const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/": <HQ />, "/": <HQ />,
"/map": ( "/map": (
<Suspense fallback={<p className="p-4 text-slate-400">Loading map…</p>}> <Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
<MapScreen /> <MapScreen />
</Suspense> </Suspense>
), ),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -186,3 +186,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde
- [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9). - [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9).
- [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed. - [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
- [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed. - [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
- [x] **Step 10E.8 — Locations, map panels, start screen and dialogs.** Locations ("Known space"): planet-art cards (two columns from `md`), emerald tone and an HQ pill where you have a bay, distance in cyan, specs on an inset `dl`, View contracts as a button, Lease bay as a full-width primary; undiscovered places dimmed and greyed with a padlock. Map: Reset view and the tap panels restyled (glass panel with the location's or ship's art, icon close button, View contracts as a button; a side card from `md`). Start screen is a title screen: gradient DEADHEAD wordmark, the Sun, Earth, Moon and a Courier around it, and a large Start button. "While you were away" and the deal are on `Sheet` ("Logbook" with big emerald credits and violet XP, discoveries with a star, goals panel with a success button; the deal as an amber "Incoming transmission" with the Courier's art and a large "Shake on it"). The last `slate-*` classes are gone from `apps/web/src`. Existing locations, map, onboarding, away and deal tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.