Step 10E.5: contracts, tutorial board and dispatch sheet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a69ffcc434
commit
939ee45943
|
|
@ -852,3 +852,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **"Fly a company run" on the tutorial HQ panel is the one copy addition so far**: a link to Jobs, shown only when no run is in flight and no deal is waiting. It adds a path; nothing existing changed (decision 0.5.51 is about not changing existing copy and names).
|
- **"Fly a company run" on the tutorial HQ panel is the one copy addition so far**: a link to Jobs, shown only when no run is in flight and no deal is waiting. It adds a path; nothing existing changed (decision 0.5.51 is about not changing existing copy and names).
|
||||||
- **`ScreenHeader` is a `div`, not a `header`**: a second `<header>` was exposed as another `banner` landmark next to the app header.
|
- **`ScreenHeader` is a `div`, not a `header`**: a second `<header>` was exposed as another `banner` landmark next to the app header.
|
||||||
- **Route strip labels are CSS-generated** (`after:content-[attr(data-label)]`) because tests and screen readers already get the route as text; DOM text in a decorative strip would duplicate it.
|
- **Route strip labels are CSS-generated** (`after:content-[attr(data-label)]`) because tests and screen readers already get the route as text; DOM text in a decorative strip would duplicate it.
|
||||||
|
|
||||||
|
## Step 10E.5
|
||||||
|
|
||||||
|
- **Sheets portal to `document.body`.** The screen column animates in with a `transform`, which makes it the containing block for `position: fixed`; the first dispatch-sheet screenshot showed the backdrop but the sheet stranded inside the column, under the header and nav. Portalling fixes all sheets at once and keeps tests working (they query `screen`).
|
||||||
|
- **Contract cards keep their texts in the same order and grouping** ("Standard (tier 2) · 50 t · …", "Needs …", "expires in …") so the board tests stay unchanged, and they contain no nested list items (tests count `listitem`s).
|
||||||
|
- **Dispatch outlook cells wrap each `dt`/`dd` pair in a `div`** (valid inside `dl`), so text still reads "Trip2h 05m".
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Contract } from "@deadhead/schema";
|
import type { Contract } from "@deadhead/schema";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Screen, ScreenHeader } from "../ui";
|
||||||
import { LocationBoards } from "./LocationBoards";
|
import { LocationBoards } from "./LocationBoards";
|
||||||
import { DispatchSheet } from "./DispatchSheet";
|
import { DispatchSheet } from "./DispatchSheet";
|
||||||
import { TutorialBoard } from "./TutorialBoard";
|
import { TutorialBoard } from "./TutorialBoard";
|
||||||
|
|
@ -10,10 +11,14 @@ export function Contracts() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const [selected, setSelected] = useState<Contract | null>(null);
|
const [selected, setSelected] = useState<Contract | null>(null);
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 p-4">
|
<Screen className="max-w-4xl">
|
||||||
<h1 className="text-xl font-semibold">Contracts</h1>
|
<ScreenHeader
|
||||||
|
title="Contracts"
|
||||||
|
eyebrow={state.tutorial ? "Halvorsen Lunar Freight" : "Freight market"}
|
||||||
|
icon="jobs"
|
||||||
|
/>
|
||||||
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
||||||
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
|
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
|
||||||
</section>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import {
|
||||||
import { requirementsText } from "./LocationBoards";
|
import { requirementsText } from "./LocationBoards";
|
||||||
import { location, shipModel, tripRealMs } from "../game";
|
import { location, shipModel, tripRealMs } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
|
import { Art, Button, Sheet } from "../ui";
|
||||||
|
|
||||||
/** Short player-facing reasons a ship+pilot cannot take a contract. */
|
/** Short player-facing reasons a ship+pilot cannot take a contract. */
|
||||||
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||||
|
|
@ -107,6 +109,8 @@ export interface PairOption {
|
||||||
shipId: string;
|
shipId: string;
|
||||||
shipName: string;
|
shipName: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
/** The model's art (decision 0.5.50). */
|
||||||
|
modelSprite: string;
|
||||||
pilotId: string | null;
|
pilotId: string | null;
|
||||||
pilotName: string | null;
|
pilotName: string | null;
|
||||||
/** Why this pair cannot take the contract; empty when it can. */
|
/** Why this pair cannot take the contract; empty when it can. */
|
||||||
|
|
@ -186,6 +190,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
shipId: ship.id,
|
shipId: ship.id,
|
||||||
shipName: ship.name,
|
shipName: ship.name,
|
||||||
modelName: model.name,
|
modelName: model.name,
|
||||||
|
modelSprite: model.sprite,
|
||||||
pilotId: pilot?.id ?? null,
|
pilotId: pilot?.id ?? null,
|
||||||
pilotName: pilot?.name ?? null,
|
pilotName: pilot?.name ?? null,
|
||||||
problems,
|
problems,
|
||||||
|
|
@ -223,119 +228,129 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
<Sheet
|
||||||
<div
|
titleId="dispatch-title"
|
||||||
role="dialog"
|
eyebrow="Dispatch"
|
||||||
aria-modal="true"
|
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
|
||||||
aria-labelledby="dispatch-title"
|
|
||||||
className="max-h-[85dvh] w-full max-w-md overflow-y-auto rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
|
||||||
>
|
>
|
||||||
<h2 id="dispatch-title" className="text-lg font-semibold">
|
<p aria-label="Job details" className="mt-1 text-sm text-ink-300">
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
|
||||||
</h2>
|
|
||||||
<p aria-label="Job details" className="text-sm text-slate-400">
|
|
||||||
{cargoName(contract.cargoTypeId)}
|
{cargoName(contract.cargoTypeId)}
|
||||||
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
|
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
|
||||||
{formatCredits(contract.payout)}
|
{formatCredits(contract.payout)}
|
||||||
{" · "}
|
{" · "}
|
||||||
{requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
|
{requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<RouteStrip originId={contract.originId} destinationId={contract.destinationId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-slate-400">
|
<p className="mt-3 text-sm text-ink-400">
|
||||||
No idle ships at {locationName(contract.originId)}.
|
No idle ships at {locationName(contract.originId)}.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
{options.map((option) => (
|
{options.map((option) => {
|
||||||
|
const blocked = option.problems.length > 0;
|
||||||
|
const picked = chosen === option.shipId;
|
||||||
|
return (
|
||||||
<li key={option.shipId}>
|
<li key={option.shipId}>
|
||||||
<label
|
<label
|
||||||
aria-disabled={option.problems.length > 0}
|
aria-disabled={blocked}
|
||||||
className={`block rounded-lg border p-3 text-sm ${
|
className={`block rounded-xl border p-3 text-sm transition ${
|
||||||
option.problems.length > 0
|
blocked
|
||||||
? "border-slate-800 opacity-60"
|
? "border-space-700 bg-space-950/40 opacity-70"
|
||||||
: chosen === option.shipId
|
: picked
|
||||||
? "border-amber-400"
|
? "cursor-pointer border-amber-400 bg-amber-500/10 shadow-glow-amber"
|
||||||
: "border-slate-700"
|
: "cursor-pointer border-space-600 bg-space-900/60 hover:border-space-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="pair"
|
name="pair"
|
||||||
value={option.shipId}
|
value={option.shipId}
|
||||||
disabled={option.problems.length > 0}
|
disabled={blocked}
|
||||||
checked={chosen === option.shipId}
|
checked={picked}
|
||||||
onChange={() => setChosen(option.shipId)}
|
onChange={() => setChosen(option.shipId)}
|
||||||
|
className="size-4 shrink-0 accent-amber-400"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{option.shipName}</span>
|
<Art sprite={option.modelSprite} className="size-10 shrink-0" />
|
||||||
<span className="text-slate-400">
|
<span className="min-w-0">
|
||||||
|
<span className="block font-display font-bold tracking-wide">
|
||||||
|
{option.shipName}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-ink-400">
|
||||||
{option.modelName} · {option.pilotName ?? "no pilot"}
|
{option.modelName} · {option.pilotName ?? "no pilot"}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{option.problems.length > 0 ? (
|
{blocked ? (
|
||||||
<ul aria-label="Problems" className="mt-1 list-disc pl-5 text-red-400">
|
<ul aria-label="Problems" className="mt-2 space-y-0.5 pl-1 text-rose-300">
|
||||||
{option.problems.map((p) => (
|
{option.problems.map((p) => (
|
||||||
<li key={p}>{p}</li>
|
<li
|
||||||
|
key={p}
|
||||||
|
className="flex gap-1.5 before:text-rose-400 before:content-['✕']"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<dl className="mt-1 grid grid-cols-2 gap-x-3 text-slate-300">
|
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
<dt>Trip</dt>
|
<Cell label="Trip" value={formatDuration(option.tripMs)} />
|
||||||
<dd className="tabular-nums">{formatDuration(option.tripMs)}</dd>
|
<Cell label="Fuel" value={formatCredits(option.fuelCost)} />
|
||||||
<dt>Fuel</dt>
|
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
|
||||||
<dd className="tabular-nums">{formatCredits(option.fuelCost)}</dd>
|
<Cell
|
||||||
<dt>Net profit</dt>
|
label="ETA (game)"
|
||||||
<dd className="tabular-nums">{formatCredits(option.netProfit)}</dd>
|
value={formatGameTime(realToGame(option.arrivalAt, content.config))}
|
||||||
<dt>ETA (game)</dt>
|
/>
|
||||||
<dd className="tabular-nums">
|
<Cell label="ETA (real)" value={formatLocalTime(option.arrivalAt)} />
|
||||||
{formatGameTime(realToGame(option.arrivalAt, content.config))}
|
|
||||||
</dd>
|
|
||||||
<dt>ETA (real)</dt>
|
|
||||||
<dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd>
|
|
||||||
{option.duty && (
|
{option.duty && (
|
||||||
<>
|
<Cell
|
||||||
<dt>Duty after</dt>
|
label="Duty after"
|
||||||
<dd className="tabular-nums">
|
value={`${formatGameHours(option.duty.after)} of ${formatGameHours(option.duty.limit)}`}
|
||||||
{formatGameHours(option.duty.after)} of{" "}
|
/>
|
||||||
{formatGameHours(option.duty.limit)}
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
{option.problems.length === 0 && option.duty?.willRest && (
|
{!blocked && option.duty?.willRest && (
|
||||||
<p className="mt-1 text-amber-300">Will need rest on arrival</p>
|
<p className="mt-2 text-amber-300">Will need rest on arrival</p>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dispatch.isError && (
|
{dispatch.isError && (
|
||||||
<p role="alert" className="mt-3 text-sm text-red-400">
|
<p role="alert" className="mt-3 text-sm text-rose-300">
|
||||||
{errorMessage(dispatch.error)}
|
{errorMessage(dispatch.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
<button
|
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded-lg border border-slate-700 py-2"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button onClick={confirm} disabled={!selected || dispatch.isPending} className="flex-1">
|
||||||
type="button"
|
|
||||||
onClick={confirm}
|
|
||||||
disabled={!selected || dispatch.isPending}
|
|
||||||
className="flex-1 rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One number in a pair's outlook; `dt` directly before `dd` ("Trip2h 05m"). */
|
||||||
|
function Cell({ label, value, good }: { label: string; value: string; good?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-space-950/60 px-2 py-1.5 ring-1 ring-space-700">
|
||||||
|
<dt className="font-display text-[10px] tracking-[0.14em] text-ink-400 uppercase">{label}</dt>
|
||||||
|
<dd
|
||||||
|
className={`font-display text-sm font-semibold tabular-nums ${good ? "text-emerald-300" : "text-ink-50"}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import { distanceAt } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { pairOptions } from "./DispatchSheet";
|
import { pairOptions } from "./DispatchSheet";
|
||||||
import { DeadheadPicker } from "./Fleet";
|
import { DeadheadPicker } from "./Fleet";
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
|
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
||||||
|
|
||||||
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
||||||
|
|
||||||
|
|
@ -54,23 +57,29 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
||||||
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
|
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div role="group" aria-label="Location" className="flex flex-wrap gap-2">
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Location"
|
||||||
|
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1"
|
||||||
|
>
|
||||||
{discovered.map((id) => {
|
{discovered.map((id) => {
|
||||||
const idle = idleAt(id);
|
const idle = idleAt(id);
|
||||||
|
const sprite = content.location(id)?.sprite;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={id === locationId}
|
aria-pressed={id === locationId}
|
||||||
onClick={() => setParams({ location: id }, { replace: true })}
|
onClick={() => setParams({ location: id }, { replace: true })}
|
||||||
className="flex items-center gap-1.5 rounded-full border border-slate-700 px-3 py-1 text-sm aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-200"
|
className="flex shrink-0 items-center gap-2 rounded-full border border-space-600 bg-space-900/70 py-1 pr-3 pl-1 font-display text-sm font-semibold tracking-wide text-ink-300 backdrop-blur transition hover:border-space-500 hover:text-ink-50 aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-100 aria-pressed:shadow-glow-amber"
|
||||||
>
|
>
|
||||||
|
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
|
||||||
{locationName(id)}
|
{locationName(id)}
|
||||||
{idle > 0 && (
|
{idle > 0 && (
|
||||||
<span
|
<span
|
||||||
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
|
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
|
||||||
className="rounded-full bg-sky-500/25 px-1.5 text-xs tabular-nums text-sky-200"
|
className="grid h-5 min-w-5 place-items-center rounded-full bg-cyan-400/20 px-1.5 text-xs text-cyan-200 tabular-nums ring-1 ring-cyan-400/40"
|
||||||
>
|
>
|
||||||
{idle}
|
{idle}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -80,7 +89,8 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{idleAt(locationId) === 0 && (
|
{idleAt(locationId) === 0 && (
|
||||||
<p className="text-sm text-slate-400">
|
<p className="flex items-center gap-2 rounded-lg border border-space-700 bg-space-900/60 px-3 py-2 text-sm text-ink-300">
|
||||||
|
<Icon name="alert" className="size-4 shrink-0 text-amber-300" />
|
||||||
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
|
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -108,77 +118,97 @@ function LocationBoard({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>;
|
if (board.isPending) return <p className="text-ink-400">Loading contracts…</p>;
|
||||||
if (board.isError) {
|
if (board.isError) {
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="text-red-400">
|
<div role="alert" className="flex items-center gap-3 text-rose-300">
|
||||||
Could not load contracts.{" "}
|
Could not load contracts.{" "}
|
||||||
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
<Button variant="danger" size="sm" onClick={() => void board.refetch()}>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const refresh = (
|
const refresh = (
|
||||||
<p aria-label="Board refresh" className="text-sm text-slate-400">
|
<p
|
||||||
|
aria-label="Board refresh"
|
||||||
|
className="flex items-center gap-1.5 font-display text-xs tracking-wider text-ink-400 uppercase"
|
||||||
|
>
|
||||||
|
<Icon name="clock" className="size-3.5 text-cyan-300" />
|
||||||
New contracts in{" "}
|
New contracts in{" "}
|
||||||
|
<span className="text-cyan-200">
|
||||||
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
|
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
|
||||||
if (board.data.contracts.length === 0) {
|
if (board.data.contracts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{refresh}
|
{refresh}
|
||||||
{stuck}
|
{stuck}
|
||||||
<p className="text-sm text-slate-400">No contracts left this period.</p>
|
<Panel tone="muted" className="text-center text-sm text-ink-400">
|
||||||
|
No contracts left this period.
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{refresh}
|
{refresh}
|
||||||
{stuck}
|
{stuck}
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
{board.data.contracts.map((contract) => {
|
{board.data.contracts.map((contract) => {
|
||||||
const requirements = requirementsText(contract);
|
const requirements = requirementsText(contract);
|
||||||
return (
|
return (
|
||||||
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
|
<Panel
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
as="li"
|
||||||
<span className="font-medium">
|
key={contract.id}
|
||||||
|
tone={contract.firstContact ? "amber" : "default"}
|
||||||
|
feature={contract.firstContact}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-display font-bold tracking-wide">
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
{contract.firstContact && (
|
{contract.firstContact && (
|
||||||
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
<Pill tone="amber" icon="star" className="ml-2 align-middle">
|
||||||
First contact
|
First contact
|
||||||
</span>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold tabular-nums text-emerald-400">
|
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
|
||||||
{formatCredits(contract.payout)}
|
{formatCredits(contract.payout)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-slate-400">
|
<RouteStrip
|
||||||
|
originId={contract.originId}
|
||||||
|
destinationId={contract.destinationId}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-ink-300">
|
||||||
|
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
|
||||||
|
<span>
|
||||||
{cargoName(contract.cargoTypeId)}
|
{cargoName(contract.cargoTypeId)}
|
||||||
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
|
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
|
||||||
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center justify-between gap-2 text-sm">
|
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
|
||||||
<span className="text-slate-400">
|
<span className="text-ink-400">
|
||||||
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
|
<span className={requirements ? "text-amber-200" : "text-ink-300"}>
|
||||||
<Countdown to={contract.expiresAt} done="expired" />
|
{requirements ? `Needs ${requirements}` : "No skills needed"}
|
||||||
|
</span>{" "}
|
||||||
|
· expires in <Countdown to={contract.expiresAt} done="expired" />
|
||||||
</span>
|
</span>
|
||||||
{onSelect && (
|
{onSelect && (
|
||||||
<button
|
<Button size="sm" onClick={() => onSelect(contract)} className="shrink-0">
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(contract)}
|
|
||||||
className="shrink-0 rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
|
|
||||||
>
|
|
||||||
Dispatch
|
Dispatch
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -218,37 +248,37 @@ function NothingHere({
|
||||||
{stuck.map((ship) => {
|
{stuck.map((ship) => {
|
||||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id)!;
|
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id)!;
|
||||||
return (
|
return (
|
||||||
<div
|
<Panel
|
||||||
key={ship.id}
|
key={ship.id}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`Nothing for ${ship.name}`}
|
aria-label={`Nothing for ${ship.name}`}
|
||||||
className="rounded-lg border border-sky-900 bg-sky-950/40 p-3 text-sm"
|
tone="cyan"
|
||||||
|
className="text-sm"
|
||||||
>
|
>
|
||||||
<p>
|
<p className="flex items-center gap-2 text-cyan-100">
|
||||||
|
<Icon name="alert" className="size-4 shrink-0 text-cyan-300" />
|
||||||
No job here that {ship.name} and {pilot.name} can take.
|
No job here that {ship.name} and {pilot.name} can take.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
disabled={rest.isPending || state.tutorial !== null}
|
disabled={rest.isPending || state.tutorial !== null}
|
||||||
onClick={() => rest.mutate({ pilotId: pilot.id })}
|
onClick={() => rest.mutate({ pilotId: pilot.id })}
|
||||||
className="rounded-md border border-slate-700 px-3 py-1"
|
|
||||||
>
|
>
|
||||||
|
<Icon name="rest" className="size-4" />
|
||||||
Rest {pilot.name} — wakes to a new board
|
Rest {pilot.name} — wakes to a new board
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="secondary" size="sm" onClick={() => setDeadheading(ship)}>
|
||||||
type="button"
|
<Icon name="send" className="size-4" />
|
||||||
onClick={() => setDeadheading(ship)}
|
|
||||||
className="rounded-md border border-slate-700 px-3 py-1"
|
|
||||||
>
|
|
||||||
Deadhead (fly empty)
|
Deadhead (fly empty)
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{rest.isError && (
|
{rest.isError && (
|
||||||
<p role="alert" className="text-sm text-red-400">
|
<p role="alert" className="text-sm text-rose-300">
|
||||||
{errorMessage(rest.error)}
|
{errorMessage(rest.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { useClock } from "../clock/clock";
|
||||||
import { formatCredits, formatDuration, locationName, cargoName } from "../format";
|
import { formatCredits, formatDuration, locationName, cargoName } from "../format";
|
||||||
import { distanceAt, tripRealMs } from "../game";
|
import { distanceAt, tripRealMs } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
|
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
||||||
|
|
||||||
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
|
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
|
||||||
|
|
||||||
|
|
@ -23,46 +25,49 @@ export function TutorialBoard() {
|
||||||
const loaner = loanerModel(content);
|
const loaner = loanerModel(content);
|
||||||
const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0;
|
const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0;
|
||||||
|
|
||||||
if (board.isPending) return <p className="p-4 text-slate-400">Loading contracts…</p>;
|
if (board.isPending) return <p className="p-4 text-ink-400">Loading contracts…</p>;
|
||||||
if (board.isError) {
|
if (board.isError) {
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="p-4 text-red-400">
|
<div role="alert" className="flex items-center gap-3 p-4 text-rose-300">
|
||||||
Could not load contracts.{" "}
|
Could not load contracts.{" "}
|
||||||
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
<Button variant="danger" size="sm" onClick={() => void board.refetch()}>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{firstRun && (
|
{firstRun && (
|
||||||
<div
|
<Panel aria-label="Intro" feature tone="amber" className="flex items-center gap-3 text-sm">
|
||||||
aria-label="Intro"
|
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
|
||||||
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
<div>
|
||||||
>
|
<p className="font-display text-base font-bold text-amber-200">
|
||||||
<p className="font-semibold text-amber-200">Halvorsen Lunar Freight needs a pilot.</p>
|
Halvorsen Lunar Freight needs a pilot.
|
||||||
<p className="text-slate-300">Their ship, your hands. Get the crates to the Moon.</p>
|
</p>
|
||||||
|
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-ink-300">
|
||||||
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
||||||
</p>
|
</p>
|
||||||
{runInFlight && (
|
{runInFlight && (
|
||||||
<p className="rounded-lg bg-slate-800 p-2 text-sm">
|
<p className="flex items-center gap-2 rounded-lg border border-cyan-400/40 bg-cyan-950/50 px-3 py-2 text-sm text-cyan-100">
|
||||||
|
<Icon name="send" className="size-4 shrink-0 text-cyan-300" />
|
||||||
Your run is in flight. Wait for it to land.
|
Your run is in flight. Wait for it to land.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{fly.isError && (
|
{fly.isError && (
|
||||||
<p role="alert" className="text-sm text-red-400">
|
<p role="alert" className="text-sm text-rose-300">
|
||||||
{errorMessage(fly.error)}
|
{errorMessage(fly.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{board.data.contracts.length === 0 && (
|
{board.data.contracts.length === 0 && (
|
||||||
<p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p>
|
<p className="text-sm text-ink-400">No company runs left this period. Check back soon.</p>
|
||||||
)}
|
)}
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
{board.data.contracts.map((contract, index) => {
|
{board.data.contracts.map((contract, index) => {
|
||||||
// On the first run, suggest the lightest (fastest) contract.
|
// On the first run, suggest the lightest (fastest) contract.
|
||||||
const suggested = firstRun && index === 0;
|
const suggested = firstRun && index === 0;
|
||||||
|
|
@ -81,74 +86,90 @@ export function TutorialBoard() {
|
||||||
contract.tier,
|
contract.tier,
|
||||||
content.config,
|
content.config,
|
||||||
);
|
);
|
||||||
const border = scripted
|
|
||||||
? "border-fuchsia-500 bg-fuchsia-500/10"
|
|
||||||
: suggested
|
|
||||||
? "border-amber-400 ring-1 ring-amber-400"
|
|
||||||
: "border-slate-800";
|
|
||||||
return (
|
return (
|
||||||
<li
|
<Panel
|
||||||
|
as="li"
|
||||||
key={contract.id}
|
key={contract.id}
|
||||||
data-suggested={suggested || undefined}
|
data-suggested={suggested || undefined}
|
||||||
data-scripted={scripted ? true : undefined}
|
data-scripted={scripted ? true : undefined}
|
||||||
className={`rounded-lg border p-3 ${border}`}
|
tone={scripted ? "fuchsia" : suggested ? "amber" : "default"}
|
||||||
|
feature={Boolean(scripted) || suggested}
|
||||||
|
className={`flex flex-col gap-2 ${suggested ? "shadow-glow-amber" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="font-medium">
|
<span className="font-display font-bold tracking-wide">
|
||||||
{scripted ? (
|
{scripted ? (
|
||||||
<span className="text-fuchsia-300">{scripted.name}</span>
|
<span className="text-fuchsia-200">{scripted.name}</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{suggested && (
|
{suggested && (
|
||||||
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
<Pill tone="amber" icon="star" className="ml-2 align-middle">
|
||||||
Suggested
|
Suggested
|
||||||
</span>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-slate-400">
|
<span className="flex items-center gap-1 text-sm whitespace-nowrap text-ink-300">
|
||||||
|
<Icon name="cargo" className="size-4 text-ink-400" />
|
||||||
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
|
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{scripted && (
|
{scripted && (
|
||||||
<p className="mt-1 text-xs text-fuchsia-200">
|
<p className="text-xs text-fuchsia-200">
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
|
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
|
||||||
double pay, no questions asked
|
double pay, no questions asked
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
<RouteStrip
|
||||||
<dl className="grid flex-1 grid-cols-3 text-sm">
|
originId={contract.originId}
|
||||||
|
destinationId={contract.destinationId}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2">
|
||||||
|
<dl className="grid flex-1 grid-cols-3 gap-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-slate-400">Pay</dt>
|
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
|
||||||
<dd aria-label="Pay" className="font-semibold tabular-nums text-emerald-400">
|
Pay
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
aria-label="Pay"
|
||||||
|
className="font-display text-base font-bold text-emerald-300 tabular-nums"
|
||||||
|
>
|
||||||
{formatCredits(contract.payout)}
|
{formatCredits(contract.payout)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-slate-400">Time</dt>
|
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
|
||||||
<dd aria-label="Trip time" className="tabular-nums">
|
Time
|
||||||
|
</dt>
|
||||||
|
<dd aria-label="Trip time" className="font-display text-base tabular-nums">
|
||||||
{formatDuration(tripMs)}
|
{formatDuration(tripMs)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-slate-400">XP</dt>
|
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
|
||||||
<dd aria-label="XP" className="tabular-nums">
|
XP
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
aria-label="XP"
|
||||||
|
className="font-display text-base text-violet-300 tabular-nums"
|
||||||
|
>
|
||||||
+{xp}
|
+{xp}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="sm"
|
||||||
disabled={runInFlight || fly.isPending}
|
disabled={runInFlight || fly.isPending}
|
||||||
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
||||||
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-slate-950 disabled:opacity-40"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
Fly it
|
Fly it
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import type { Tone } from "./Panel";
|
import type { Tone } from "./Panel";
|
||||||
|
|
||||||
const TITLE_TONES: Partial<Record<Tone, string>> = {
|
const TITLE_TONES: Partial<Record<Tone, string>> = {
|
||||||
|
|
@ -14,7 +15,9 @@ const BORDER_TONES: Partial<Record<Tone, string>> = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A modal dialog: a bottom sheet on phones, a centred card from `sm` up.
|
* A modal dialog: a bottom sheet on phones, a centred card from `sm` up.
|
||||||
* Labelled by its title, which it renders as an `h2` with `titleId`.
|
* Labelled by its title, which it renders as an `h2` with `titleId`. Portalled
|
||||||
|
* to `body`: a transformed ancestor (the screens' rise-in) would otherwise
|
||||||
|
* become the containing block of `position: fixed`.
|
||||||
*/
|
*/
|
||||||
export function Sheet({
|
export function Sheet({
|
||||||
titleId,
|
titleId,
|
||||||
|
|
@ -34,7 +37,7 @@ export function Sheet({
|
||||||
layer?: string;
|
layer?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
|
className={`fixed inset-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||||
>
|
>
|
||||||
|
|
@ -62,6 +65,7 @@ export function Sheet({
|
||||||
{subtitle && <div className="mt-1 text-sm text-ink-300">{subtitle}</div>}
|
{subtitle && <div className="mt-1 text-sm text-ink-300">{subtitle}</div>}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,3 +183,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde
|
||||||
- [x] **Step 10E.2 — UI kit.** `apps/web/src/ui/` (barrel `index.ts`): `Button`/`buttonClass` (primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback), `Panel` (tones, `feature` corner brackets, polymorphic `as`) and `Eyebrow`, `Sheet` (modal: bottom sheet on phones, centred card from `sm`, renders its own `h2`), `Pill`, `Meter` (labelled `progressbar`), `StatTile`, `ScreenHeader` (the screen's `h1`), `Art`/`spriteUrl` (generated map sprites, 1x/2x `srcSet`, hides on error) and `Icon` (28 line icons). Test: `ui-kit.test.tsx`.
|
- [x] **Step 10E.2 — UI kit.** `apps/web/src/ui/` (barrel `index.ts`): `Button`/`buttonClass` (primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback), `Panel` (tones, `feature` corner brackets, polymorphic `as`) and `Eyebrow`, `Sheet` (modal: bottom sheet on phones, centred card from `sm`, renders its own `h2`), `Pill`, `Meter` (labelled `progressbar`), `StatTile`, `ScreenHeader` (the screen's `h1`), `Art`/`spriteUrl` (generated map sprites, 1x/2x `srcSet`, hides on error) and `Icon` (28 line icons). Test: `ui-kit.test.tsx`.
|
||||||
- [x] **Step 10E.3 — App shell.** `Layout`: HUD header (logo mark, wordmark from 420 px, glowing credits chip, clock chip; `Credits` / `Game clock` labels kept) and icon navigation, a bottom bar on phones and a 96 px left rail from `md` (decision 0.5.49). `SCREENS` gained an `icon`. Attention badges from the pure `tabAttention(state)` (`layout/attention.ts`): HQ = goals ready, Jobs = idle crewed ships whose pilot isn't resting (1 during the tutorial until a run is in flight), Pilots = pilots with unspent skill points; badges are `aria-hidden`. `--header-h`/`--nav-h` CSS variables let the map fill exactly the space left on both layouts. `ui/Screen` is the content column (max 48 rem, rise-in). App loading, error and offline states use the kit. Test: `attention.test.tsx` (counts; badge doesn't change the tab's name); `layout.test.tsx` unchanged and passing.
|
- [x] **Step 10E.3 — App shell.** `Layout`: HUD header (logo mark, wordmark from 420 px, glowing credits chip, clock chip; `Credits` / `Game clock` labels kept) and icon navigation, a bottom bar on phones and a 96 px left rail from `md` (decision 0.5.49). `SCREENS` gained an `icon`. Attention badges from the pure `tabAttention(state)` (`layout/attention.ts`): HQ = goals ready, Jobs = idle crewed ships whose pilot isn't resting (1 during the tutorial until a run is in flight), Pilots = pilots with unspent skill points; badges are `aria-hidden`. `--header-h`/`--nav-h` CSS variables let the map fill exactly the space left on both layouts. `ui/Screen` is the content column (max 48 rem, rise-in). App loading, error and offline states use the kit. Test: `attention.test.tsx` (counts; badge doesn't change the tab's name); `layout.test.tsx` unchanged and passing.
|
||||||
- [x] **Step 10E.4 — HQ dashboard.** `ScreenHeader` with the company name; icon stat tiles (2 × 2 on phones, 4 across from `md`); two columns from `md`. New `components/RouteStrip` (origin art → dashed line → destination art; with `progress` the line fills and the ship's art rides along; `aria-hidden`, place names drawn with CSS `content` so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; `GoalsCard` rows with check discs, reward amounts and a glowing Claim; `LoanCard` with an emerald `Meter` and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. **Manual check:** scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9).
|
- [x] **Step 10E.4 — HQ dashboard.** `ScreenHeader` with the company name; icon stat tiles (2 × 2 on phones, 4 across from `md`); two columns from `md`. New `components/RouteStrip` (origin art → dashed line → destination art; with `progress` the line fills and the ship's art rides along; `aria-hidden`, place names drawn with CSS `content` so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; `GoalsCard` rows with check discs, reward amounts and a glowing Claim; `LoanCard` with an emerald `Meter` and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. **Manual check:** scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9).
|
||||||
|
- [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue