Step 29.5: Bridge leads with your ship and Needs you

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-11-04 18:16:17 -05:00
parent 38b636014f
commit 9aada41e8f
11 changed files with 424 additions and 213 deletions

View File

@ -15,8 +15,8 @@ export function GoalsCard() {
return ( return (
<Panel as="section" id="goals" aria-labelledby="goals-title"> <Panel as="section" id="goals" aria-labelledby="goals-title">
<h2 id="goals-title" className="flex items-center gap-2 font-display text-lg font-bold"> <h2 id="goals-title" className="flex items-center gap-2 text-sm font-semibold">
<Icon name="target" className="size-5 text-amber-300" /> <Icon name="target" className="size-4 text-ink-300" />
Goals Goals
</h2> </h2>
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
@ -28,23 +28,21 @@ export function GoalsCard() {
<li <li
key={goal.id} key={goal.id}
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${ className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
ready ready ? "border-earned/45 bg-earned/8" : "border-glass-line"
? "border-emerald-400/50 bg-emerald-500/10 shadow-glow-emerald"
: "border-space-700 bg-space-950/40"
}`} }`}
> >
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span <span
aria-hidden="true" aria-hidden="true"
className={`grid size-5 shrink-0 place-items-center rounded-full ring-1 ${ 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" />} {ready && <Icon name="check" className="size-3.5" />}
</span> </span>
<span className={ready ? "text-emerald-200" : "text-ink-200"}> <span className={"text-ink-100"}>
{details.title} {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)} +{formatCredits(details.reward)}
</span> </span>
</span> </span>
@ -55,7 +53,7 @@ export function GoalsCard() {
size="sm" size="sm"
disabled={claim.isPending} disabled={claim.isPending}
onClick={() => claim.mutate({ goalId: goal.id })} onClick={() => claim.mutate({ goalId: goal.id })}
className="shrink-0 animate-glow" className="shrink-0"
> >
Claim Claim
</Button> </Button>

View File

@ -27,9 +27,9 @@ export function DispatchOfficePanel() {
Dispatch Office Dispatch Office
</p> </p>
<p className="mt-1 text-ink-300"> <p className="mt-1 text-ink-300">
Your hired pilots can run jobs on their own. Set them to Independent on{" "} Your hired pilots can run jobs on their own. Set them to Independent in{" "}
<Link to="/pilots" className="font-semibold text-amber-300 underline"> <Link to="/pilots" className="font-semibold underline">
Pilots Crew
</Link> </Link>
. .
</p> </p>
@ -37,13 +37,15 @@ export function DispatchOfficePanel() {
); );
} }
return ( return (
<Panel tone="amber" className="text-sm" aria-label="Dispatch Office"> <Panel className="text-sm" aria-label="Dispatch Office">
<p className="font-display font-semibold text-amber-100">Dispatch Office</p> <p className="font-semibold">Dispatch Office</p>
<p className="mt-1 text-ink-300"> <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 Lets your hired pilots run jobs on their own, so the company keeps earning while you&apos;re
away. away.
</p> </p>
<Button <Button
variant="secondary"
size="sm"
className="mt-3" className="mt-3"
disabled={state.company.credits < price || buy.isPending} disabled={state.company.credits < price || buy.isPending}
onClick={() => buy.mutate({})} onClick={() => buy.mutate({})}

View File

@ -26,11 +26,8 @@ export function LoanCard() {
return ( return (
<Panel> <Panel>
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<h2 className="font-display text-lg font-bold">Loan from Halvorsen</h2> <h2 className="text-sm font-semibold">Loan from Halvorsen</h2>
<span <span aria-label="Loan balance" className="font-semibold tabular-nums">
aria-label="Loan balance"
className="font-display font-semibold text-amber-200 tabular-nums"
>
{formatCredits(loanBalance)} left {formatCredits(loanBalance)} left
</span> </span>
</div> </div>
@ -39,9 +36,7 @@ export function LoanCard() {
until it&apos;s paid off. until it&apos;s paid off.
</p> </p>
<Meter label="Loan paid off" value={paidOff} tone="emerald" className="mt-3" /> <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"> <p className="mt-1 text-right text-xs text-ink-300">{paidOff}% paid off</p>
{paidOff}% paid off
</p>
<form <form
className="mt-2 flex items-center gap-2" className="mt-2 flex items-center gap-2"
@ -59,9 +54,13 @@ export function LoanCard() {
placeholder="Amount" placeholder="Amount"
value={input} value={input}
onChange={(e) => setInput(e.target.value)} 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 Pay down
</Button> </Button>
</form> </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

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

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { FleetAsleep } from "../components/FleetSleep"; import { FleetAsleep } from "../components/FleetSleep";
import { RadioCallsPanel } from "../components/RadioCalls"; import { NeedsYou } from "../components/NeedsYou";
import { DispatchOfficePanel } from "../components/IndependentPilots"; import { DispatchOfficePanel } from "../components/IndependentPilots";
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema"; import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
import { dispatchProgress } from "@deadhead/sim"; import { dispatchProgress } from "@deadhead/sim";
@ -97,27 +97,11 @@ export function Dashboard() {
<ScreenHeader title="Bridge" eyebrow={state.company.name} icon="helm" /> <ScreenHeader title="Bridge" eyebrow={state.company.name} icon="helm" />
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */} {/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
<FleetAsleep /> <FleetAsleep />
{/* Calls waiting for an answer, each reopening its card (CR-026). */}
<RadioCallsPanel />
<div className="grid gap-4 @2xl:grid-cols-3">
<YourShip className="@2xl:col-span-2" />
<Alerts />
</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 @2xl:grid-cols-2">
<div className="space-y-4">
{state.tutorial && ( {state.tutorial && (
<Panel tone="amber"> <Panel tone="amber">
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow> <Eyebrow>Contract pilot</Eyebrow>
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200"> <h2 className="mt-0.5 text-lg font-semibold">Flying for {content.tutorialCompanyName}</h2>
Flying for {content.tutorialCompanyName}
</h2>
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200"> <p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "} Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
{runsBeforeDeal} {runsBeforeDeal}
@ -135,30 +119,36 @@ export function Dashboard() {
</Panel> </Panel>
)} )}
<Panel <div className="grid gap-4 @2xl:grid-cols-2">
as="section" <YourShip />
aria-labelledby="on-jobs-title" <NeedsYou />
tone={onJobs.length ? "cyan" : "default"} </div>
>
<h2 id="on-jobs-title" className="font-display text-xs font-semibold text-cyan-300/80"> <section aria-labelledby="on-jobs-title">
Ships on jobs <div className="flex items-baseline justify-between">
<h2 id="on-jobs-title" className="text-sm font-semibold">
In flight
</h2> </h2>
<span aria-label="Ships in flight" className="text-xs text-ink-300">
{flying} flying · {idle} idle
</span>
</div>
{onJobs.length === 0 ? ( {onJobs.length === 0 ? (
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p> <p className="py-2 text-sm text-ink-300">Nothing in flight.</p>
) : ( ) : (
<ul className="mt-2 space-y-3"> <ul className="divide-y divide-glass-line">
{onJobs.map((d) => { {onJobs.map((d) => {
const name = flightShipName(state, d); const name = flightShipName(state, d);
return ( return (
<li key={d.id} aria-label={name} className="text-sm"> <li key={d.id} aria-label={name} className="py-2.5 text-sm">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
<span className="font-semibold text-ink-50">{name}</span>{" "} <span className="font-medium text-ink-50">{name}</span>{" "}
<span className="text-ink-300"> <span className="text-ink-300">
{locationName(d.originId)} → {locationName(d.destinationId)} {locationName(d.originId)} → {locationName(d.destinationId)}
</span> </span>
</span> </span>
<span className="font-display font-bold whitespace-nowrap text-cyan-200"> <span className="font-semibold whitespace-nowrap tabular-nums">
<Countdown to={d.arrivalAt} /> <Countdown to={d.arrivalAt} />
</span> </span>
</div> </div>
@ -167,82 +157,58 @@ export function Dashboard() {
value={Math.round(dispatchProgress(d, now) * 100)} value={Math.round(dispatchProgress(d, now) * 100)}
tone="cyan" tone="cyan"
size="sm" size="sm"
className="mt-1" className="mt-1.5"
/> />
</li> </li>
); );
})} })}
</ul> </ul>
)} )}
</Panel> </section>
{!state.tutorial && ( {!state.tutorial && resting.length > 0 && (
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}> <section aria-label="Pilot rest" className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-3"> <Icon name="rest" className="size-5 shrink-0 text-flight" />
<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"> <p className="min-w-0">
<Icon name="rest" className="size-5" /> <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> </span>
<div className="min-w-0">
<h2 className="font-display text-sm font-semibold 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>
) : ( </section>
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
)}
</div>
</div>
</Panel>
)} )}
{inShop.length > 0 && ( {inShop.length > 0 && (
<Panel aria-label="In the shop" tone="amber"> <section aria-label="In the shop" className="text-sm">
<h2 className="font-display text-sm font-semibold text-ink-50"> <h2 className="font-semibold">In the shop: {inShop.length}</h2>
In the shop: {inShop.length} <ul className="mt-1 space-y-0.5 text-ink-300">
</h2>
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
{inShop.map((ship) => ( {inShop.map((ship) => (
<li key={ship.id}> <li key={ship.id}>
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "} {ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name} {ship.shop?.level}{" "}
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" /> · ready in <Countdown to={ship.inShopUntil!} done="now" />
</li> </li>
))} ))}
</ul> </ul>
</Panel> </section>
)} )}
{!state.tutorial && state.ships.length === 0 && ( {!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-ink-400">No ships yet — buy one in the Shipyard.</p> <p className="text-sm text-ink-300">No ships yet — buy one in the Shipyard.</p>
)} )}
</div>
<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 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 && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />} {!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</div> <DispatchOfficePanel />
</div> {!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> </Screen>
); );
} }
@ -282,12 +248,12 @@ function YourShip({ className = "" }: { className?: string }) {
<Panel <Panel
as="section" as="section"
aria-label="Your ship" aria-label="Your ship"
tone={dispatch ? "cyan" : "default"} tone={dispatch || inShop ? "default" : "amber"}
className={`flex flex-col gap-3 ${className}`} className={`flex flex-col gap-3 self-start ${className}`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{sprite && ( {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 <Art
sprite={sprite} sprite={sprite}
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`} className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
@ -297,7 +263,7 @@ function YourShip({ className = "" }: { className?: string }) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Eyebrow>Your ship</Eyebrow> <Eyebrow>Your ship</Eyebrow>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<h2 className="truncate font-display text-xl font-bold">{name}</h2> <h2 className="truncate text-lg font-semibold">{name}</h2>
{dispatch ? ( {dispatch ? (
<Pill tone="cyan" icon="send"> <Pill tone="cyan" icon="send">
In flight In flight
@ -331,7 +297,7 @@ function YourShip({ className = "" }: { className?: string }) {
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}` ? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
: "Flying empty"} : "Flying empty"}
</span> </span>
<span className="font-display text-lg font-bold text-cyan-200"> <span className="font-semibold">
Arrives in <Countdown to={dispatch.arrivalAt} /> Arrives in <Countdown to={dispatch.arrivalAt} />
</span> </span>
</div> </div>
@ -428,23 +394,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 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. */ /** One pip per company run toward the deal. */
function RunPips({ done, total }: { done: number; total: number }) { function RunPips({ done, total }: { done: number; total: number }) {
return ( return (
@ -452,7 +401,7 @@ function RunPips({ done, total }: { done: number; total: number }) {
{Array.from({ length: total }, (_, i) => ( {Array.from({ length: total }, (_, i) => (
<span <span
key={i} 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> </div>

View File

@ -56,11 +56,9 @@ describe("Dashboard (CR-020)", () => {
renderApp(fakeApi({ getPlayerState: () => state })); renderApp(fakeApi({ getPlayerState: () => state }));
await screen.findByRole("heading", { level: 1, name: "Bridge" }); await screen.findByRole("heading", { level: 1, name: "Bridge" });
const statValue = (label: string) => screen.getByText(label).nextElementSibling; expect(screen.getByLabelText("Ships in flight")).toHaveTextContent("2 flying · 1 idle");
expect(statValue("Ships idle")).toHaveTextContent("1");
expect(statValue("Ships in flight")).toHaveTextContent("2");
const jobs = screen.getByRole("region", { name: "Ships on jobs" }); const jobs = screen.getByRole("region", { name: "In flight" });
const rows = within(jobs).getAllByRole("listitem"); const rows = within(jobs).getAllByRole("listitem");
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]); expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s"); expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
@ -297,9 +295,9 @@ describe("your ship on the Dashboard (CR-020)", () => {
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth"); expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
}); });
it("has an Alerts panel, empty for now", async () => { it("says so when nothing needs the player", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) })); renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
const alerts = await screen.findByRole("region", { name: "Alerts" }); const list = await screen.findByRole("region", { name: "Needs you" });
expect(alerts).toHaveTextContent("No alerts."); expect(list).toHaveTextContent("Nothing right now.");
}); });
}); });

View File

@ -43,7 +43,9 @@ describe("app layout", () => {
renderApp(signedIn()); renderApp(signedIn());
const nav = await screen.findByRole("navigation", { name: "Main" }); const nav = await screen.findByRole("navigation", { name: "Main" });
const links = within(nav).getAllByRole("link"); const links = within(nav).getAllByRole("link");
expect(links.map((l) => l.textContent)).toEqual(["Bridge", "Jobs", "Fleet", "More"]); ["Bridge", "Jobs", "Fleet", "More"].forEach((name, i) =>
expect(links[i]).toHaveAccessibleName(name),
);
expect(links[0]).toHaveAttribute("aria-current", "page"); expect(links[0]).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 1, name: "Bridge" })).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 () => { it("is reachable from the Bridge", async () => {
renderApp(app(), "/"); renderApp(app(), "/");
const card = await screen.findByLabelText("Logbook"); await userEvent.click(await screen.findByRole("link", { name: "Open the Logbook" }));
await userEvent.click(within(card).getByRole("link", { name: "Open the Logbook" }));
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
}); });
}); });

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

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