Step 10E.6: fleet and market with ship art
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
939ee45943
commit
6d5ea7cb48
|
|
@ -858,3 +858,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **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`).
|
- **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).
|
- **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".
|
- **Dispatch outlook cells wrap each `dt`/`dd` pair in a `div`** (valid inside `dl`), so text still reads "Trip2h 05m".
|
||||||
|
|
||||||
|
## Step 10E.6
|
||||||
|
|
||||||
|
- **Market spec bars are decorative (`aria-hidden`)** and relative to the best purchasable model; the numbers stay in `dt`/`dd` text. Range uses `log10(1 + range)` because a linear bar made the Courier's 5M km invisible next to the Bulk Hauler's 1,000M km.
|
||||||
|
- **Idle/In flight pills are new visible text** on Fleet cards; they restate the ship's status, which was implied before by "At …" or a route.
|
||||||
|
|
|
||||||
|
|
@ -14,25 +14,50 @@ import { Countdown } from "../components/Countdown";
|
||||||
import { formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
import { formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||||
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 {
|
||||||
|
Art,
|
||||||
|
Button,
|
||||||
|
buttonClass,
|
||||||
|
Icon,
|
||||||
|
Meter,
|
||||||
|
Panel,
|
||||||
|
Pill,
|
||||||
|
Screen,
|
||||||
|
ScreenHeader,
|
||||||
|
Sheet,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
/** All ships, their pilots and flights; any idle ship can deadhead (Steps 9.6, 10B.8). */
|
/** All ships, their pilots and flights; any idle ship can deadhead (Steps 9.6, 10B.8). */
|
||||||
export function Fleet() {
|
export function Fleet() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const [deadheading, setDeadheading] = useState<Ship | null>(null);
|
const [deadheading, setDeadheading] = useState<Ship | null>(null);
|
||||||
|
|
||||||
|
const flying = state.ships.filter((s) => s.status === "in-flight").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 p-4">
|
<Screen className="max-w-4xl">
|
||||||
<h1 className="text-xl font-semibold">Fleet</h1>
|
<ScreenHeader
|
||||||
|
title="Fleet"
|
||||||
|
eyebrow={
|
||||||
|
state.ships.length > 0
|
||||||
|
? `${state.ships.length} ${state.ships.length === 1 ? "ship" : "ships"} · ${flying} flying`
|
||||||
|
: "Your ships"
|
||||||
|
}
|
||||||
|
icon="fleet"
|
||||||
|
/>
|
||||||
{state.ships.length === 0 ? (
|
{state.ships.length === 0 ? (
|
||||||
<p className="text-sm text-slate-400">
|
<Panel tone="muted" className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<Art sprite="ship-courier" className="size-20 opacity-60" />
|
||||||
|
<p className="text-sm text-ink-300">
|
||||||
No ships yet —{" "}
|
No ships yet —{" "}
|
||||||
<Link to="/market" className="text-amber-400 underline">
|
<Link to="/market" className="font-semibold text-amber-300 underline">
|
||||||
visit the Market
|
visit the Market
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
{state.ships.map((ship) => (
|
{state.ships.map((ship) => (
|
||||||
<ShipRow
|
<ShipRow
|
||||||
key={ship.id}
|
key={ship.id}
|
||||||
|
|
@ -45,7 +70,7 @@ export function Fleet() {
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
||||||
</section>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,55 +83,79 @@ function ShipRow(props: {
|
||||||
const { ship, dispatch, pilotName } = props;
|
const { ship, dispatch, pilotName } = props;
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
|
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
|
||||||
|
const model = shipModel(ship.modelId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded-lg border border-slate-800 p-3">
|
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-medium">{ship.name}</span>
|
<div className="relative grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
|
||||||
<span className="text-sm text-slate-400">{shipModelName(ship.modelId)}</span>
|
<Art
|
||||||
|
sprite={model.sprite}
|
||||||
|
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-slate-300">
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="truncate font-display text-lg font-bold tracking-wide">
|
||||||
|
{ship.name}
|
||||||
|
</span>
|
||||||
|
{dispatch ? (
|
||||||
|
<Pill tone="cyan" icon="send">
|
||||||
|
In flight
|
||||||
|
</Pill>
|
||||||
|
) : (
|
||||||
|
<Pill tone="emerald">Idle</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div>
|
||||||
|
<div className="mt-1 text-sm text-ink-200">
|
||||||
{dispatch
|
{dispatch
|
||||||
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
||||||
: `At ${locationName(ship.locationId ?? "")}`}
|
: `At ${locationName(ship.locationId ?? "")}`}
|
||||||
{" · "}
|
{" · "}
|
||||||
<span className="text-slate-400">{pilotName ?? "No pilot"}</span>
|
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
|
||||||
|
{pilotName ?? "No pilot"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dispatch && (
|
{dispatch && (
|
||||||
<div className="mt-2">
|
<div>
|
||||||
<div
|
<Meter label={`${ship.name} progress`} value={progress} tone="cyan" />
|
||||||
role="progressbar"
|
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
|
||||||
aria-label={`${ship.name} progress`}
|
<span>{progress}%</span>
|
||||||
aria-valuemin={0}
|
<span>
|
||||||
aria-valuemax={100}
|
Arrives in{" "}
|
||||||
aria-valuenow={progress}
|
<span className="text-cyan-200">
|
||||||
className="h-2 overflow-hidden rounded bg-slate-800"
|
<Countdown to={dispatch.arrivalAt} />
|
||||||
>
|
</span>
|
||||||
<div className="h-full bg-sky-400" style={{ width: `${progress}%` }} />
|
</span>
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-right text-xs text-slate-400">
|
|
||||||
Arrives in <Countdown to={dispatch.arrivalAt} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ship.status === "idle" && ship.locationId && (
|
{ship.status === "idle" && (
|
||||||
|
<div className="mt-auto flex flex-wrap gap-2">
|
||||||
|
{ship.locationId && (
|
||||||
<Link
|
<Link
|
||||||
to={`/contracts?location=${ship.locationId}`}
|
to={`/contracts?location=${ship.locationId}`}
|
||||||
className="mt-2 mr-2 inline-block rounded-md border border-slate-700 px-3 py-1 text-sm"
|
className={`${buttonClass("primary", "sm")} flex-1 basis-40`}
|
||||||
>
|
>
|
||||||
|
<Icon name="jobs" className="size-4" />
|
||||||
Find work here
|
Find work here
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{ship.status === "idle" && (
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
type="button"
|
size="sm"
|
||||||
onClick={props.onDeadhead}
|
onClick={props.onDeadhead}
|
||||||
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
|
className="flex-1 basis-40"
|
||||||
>
|
>
|
||||||
|
<Icon name="send" className="size-4" />
|
||||||
Deadhead (fly empty)
|
Deadhead (fly empty)
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,21 +214,16 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
<Sheet
|
||||||
<div
|
titleId="deadhead-title"
|
||||||
role="dialog"
|
eyebrow="Fly empty"
|
||||||
aria-modal="true"
|
title={`Deadhead ${ship.name}`}
|
||||||
aria-labelledby="deadhead-title"
|
subtitle={`Fly empty from ${locationName(origin)} to another place you know. You pay fuel only.`}
|
||||||
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
|
||||||
>
|
>
|
||||||
<h2 id="deadhead-title" className="text-lg font-semibold">
|
<ul className="mt-4 space-y-2">
|
||||||
Deadhead {ship.name}
|
{options.map((option) => {
|
||||||
</h2>
|
const sprite = content.location(option.id)?.sprite;
|
||||||
<p className="text-sm text-slate-400">
|
return (
|
||||||
Fly empty from {locationName(origin)} to another place you know. You pay fuel only.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-3 space-y-2">
|
|
||||||
{options.map((option) => (
|
|
||||||
<li key={option.id}>
|
<li key={option.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -196,34 +240,35 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
{ onSuccess: onClose },
|
{ onSuccess: onClose },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full rounded-lg border border-slate-700 p-3 text-left disabled:opacity-50"
|
className="flex w-full items-center gap-3 rounded-xl border border-space-600 bg-space-900/60 p-3 text-left transition hover:border-amber-400/70 hover:bg-amber-500/10 active:scale-[0.99] disabled:pointer-events-none disabled:opacity-55"
|
||||||
>
|
>
|
||||||
|
{sprite && <Art sprite={sprite} className="size-10 shrink-0" />}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex items-baseline justify-between gap-2">
|
<span className="flex items-baseline justify-between gap-2">
|
||||||
<span className="font-medium">{locationName(option.id)}</span>
|
<span className="font-display font-bold tracking-wide">
|
||||||
<span className="text-sm tabular-nums text-slate-300">
|
{locationName(option.id)}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-sm text-ink-300 tabular-nums">
|
||||||
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
|
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{option.problem && (
|
{option.problem && (
|
||||||
<span className="mt-1 block text-sm text-red-400">{option.problem}</span>
|
<span className="mt-0.5 block text-sm text-rose-300">{option.problem}</span>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{reposition.isError && (
|
{reposition.isError && (
|
||||||
<p role="alert" className="mt-3 text-sm text-red-400">
|
<p role="alert" className="mt-3 text-sm text-rose-300">
|
||||||
{errorMessage(reposition.error)}
|
{errorMessage(reposition.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button variant="secondary" block onClick={onClose} className="mt-4">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="mt-4 w-full rounded-lg border border-slate-700 py-2"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Sheet>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,19 @@ import { content, type ShipModel } from "@deadhead/content";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { formatCredits, formatMillionKm, locationName } from "../format";
|
import { formatCredits, formatMillionKm, locationName } from "../format";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
|
||||||
|
|
||||||
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
|
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
|
||||||
|
|
||||||
|
const MODELS = content.purchasableShipModels;
|
||||||
|
const best = {
|
||||||
|
speed: Math.max(...MODELS.map((m) => m.cruiseSpeedKmPerGameHour)),
|
||||||
|
capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)),
|
||||||
|
range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)),
|
||||||
|
};
|
||||||
|
/** Range spans 5 to 1,000M km, so its bar uses a log scale to stay readable. */
|
||||||
|
const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range);
|
||||||
|
|
||||||
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
|
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
|
||||||
export function Market() {
|
export function Market() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
|
|
@ -12,50 +22,78 @@ export function Market() {
|
||||||
const [buying, setBuying] = useState<ShipModel | null>(null);
|
const [buying, setBuying] = useState<ShipModel | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 p-4">
|
<Screen className="max-w-5xl">
|
||||||
<h1 className="text-xl font-semibold">Market</h1>
|
<ScreenHeader
|
||||||
<p className="text-sm text-slate-400">
|
title="Market"
|
||||||
New ships are delivered with no pilot to one of your HQs.
|
eyebrow="Shipyard"
|
||||||
</p>
|
icon="market"
|
||||||
|
subtitle="New ships are delivered with no pilot to one of your HQs."
|
||||||
|
/>
|
||||||
{buy.isError && (
|
{buy.isError && (
|
||||||
<p role="alert" className="text-sm text-red-400">
|
<p role="alert" className="text-sm text-rose-300">
|
||||||
{errorMessage(buy.error)}
|
{errorMessage(buy.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{buy.isSuccess && (
|
{buy.isSuccess && (
|
||||||
<p role="status" className="text-sm text-emerald-400">
|
<p
|
||||||
|
role="status"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-emerald-400/40 bg-emerald-950/50 px-3 py-2 text-sm text-emerald-200"
|
||||||
|
>
|
||||||
|
<Icon name="check" className="size-4 shrink-0" />
|
||||||
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
|
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-4 md:grid-cols-3">
|
||||||
{content.purchasableShipModels.map((model) => {
|
{MODELS.map((model) => {
|
||||||
const affordable = state.company.credits >= model.price;
|
const affordable = state.company.credits >= model.price;
|
||||||
return (
|
return (
|
||||||
<li key={model.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
<Panel as="li" key={model.id} feature={affordable} className="flex flex-col text-sm">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="relative -mx-4 -mt-4 grid h-40 place-items-center overflow-hidden rounded-t-xl bg-radial from-cyan-400/20 via-space-850/40 to-transparent to-75%">
|
||||||
<span className="font-medium">{model.name}</span>
|
<div className="absolute inset-x-8 bottom-4 h-3 rounded-[50%] bg-cyan-300/15 blur-md" />
|
||||||
<span className="font-semibold tabular-nums">{formatCredits(model.price)}</span>
|
<Art
|
||||||
|
sprite={model.sprite}
|
||||||
|
className="relative size-32 rotate-[30deg] drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-0.5 text-slate-300">
|
<div className="mt-2 flex items-baseline justify-between gap-2">
|
||||||
<dt className="text-slate-400">Speed</dt>
|
<span className="font-display text-xl font-bold tracking-wide">{model.name}</span>
|
||||||
<dd>{formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h</dd>
|
<span
|
||||||
<dt className="text-slate-400">Capacity</dt>
|
className={`font-display text-base font-bold tabular-nums ${affordable ? "text-amber-300" : "text-ink-400"}`}
|
||||||
<dd>{model.cargoCapacityTonnes} t</dd>
|
>
|
||||||
<dt className="text-slate-400">Range</dt>
|
{formatCredits(model.price)}
|
||||||
<dd>{formatMillionKm(model.fuelRangeMillionKm)}</dd>
|
</span>
|
||||||
<dt className="text-slate-400">Landing</dt>
|
</div>
|
||||||
<dd>{LANDING_LABELS[model.landingClass]}</dd>
|
<dl className="mt-3 space-y-2 text-ink-200">
|
||||||
|
<Spec
|
||||||
|
icon="speed"
|
||||||
|
label="Speed"
|
||||||
|
value={`${formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h`}
|
||||||
|
share={model.cruiseSpeedKmPerGameHour / best.speed}
|
||||||
|
/>
|
||||||
|
<Spec
|
||||||
|
icon="cargo"
|
||||||
|
label="Capacity"
|
||||||
|
value={`${model.cargoCapacityTonnes} t`}
|
||||||
|
share={model.cargoCapacityTonnes / best.capacity}
|
||||||
|
/>
|
||||||
|
<Spec
|
||||||
|
icon="range"
|
||||||
|
label="Range"
|
||||||
|
value={formatMillionKm(model.fuelRangeMillionKm)}
|
||||||
|
share={rangeShare(model.fuelRangeMillionKm)}
|
||||||
|
/>
|
||||||
|
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
|
||||||
</dl>
|
</dl>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
aria-label={`Buy ${model.name}`}
|
aria-label={`Buy ${model.name}`}
|
||||||
|
block
|
||||||
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
||||||
onClick={() => setBuying(model)}
|
onClick={() => setBuying(model)}
|
||||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
{affordable ? "Buy" : "Not enough credits"}
|
{affordable ? "Buy" : "Not enough credits"}
|
||||||
</button>
|
</Button>
|
||||||
</li>
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -69,7 +107,30 @@ export function Market() {
|
||||||
onClose={() => setBuying(null)}
|
onClose={() => setBuying(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One spec line: label and value (dt directly before dd), with a bar relative to the best model. */
|
||||||
|
function Spec(props: { icon: IconName; label: string; value: string; share?: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<dt className="flex items-center gap-1.5 font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
<Icon name={props.icon} className="size-3.5" />
|
||||||
|
{props.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-display font-semibold tabular-nums">{props.value}</dd>
|
||||||
|
</div>
|
||||||
|
{props.share !== undefined && (
|
||||||
|
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-linear-to-r from-cyan-500 to-sky-300"
|
||||||
|
style={{ width: `${Math.max(4, props.share * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,52 +145,45 @@ function DeliveryPicker(props: {
|
||||||
const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null);
|
const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
<Sheet
|
||||||
<div
|
titleId="delivery-title"
|
||||||
role="dialog"
|
eyebrow="Delivery"
|
||||||
aria-modal="true"
|
title="Deliver to which HQ?"
|
||||||
aria-labelledby="delivery-title"
|
subtitle={`Your ${props.model.name} will wait there, idle and without a pilot.`}
|
||||||
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
|
||||||
>
|
>
|
||||||
<h2 id="delivery-title" className="text-lg font-semibold">
|
<fieldset className="mt-4 space-y-2">
|
||||||
Deliver to which HQ?
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
Your {props.model.name} will wait there, idle and without a pilot.
|
|
||||||
</p>
|
|
||||||
<fieldset className="mt-3 space-y-2">
|
|
||||||
<legend className="sr-only">HQ</legend>
|
<legend className="sr-only">HQ</legend>
|
||||||
{state.bays.map((bay) => (
|
{state.bays.map((bay) => {
|
||||||
|
const sprite = content.location(bay.locationId)?.sprite;
|
||||||
|
return (
|
||||||
<label
|
<label
|
||||||
key={bay.id}
|
key={bay.id}
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-700 p-3 text-sm"
|
className="flex cursor-pointer items-center gap-3 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm transition has-checked:border-amber-400 has-checked:bg-amber-500/10 has-checked:shadow-glow-amber"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="hq"
|
name="hq"
|
||||||
checked={bayId === bay.id}
|
checked={bayId === bay.id}
|
||||||
onChange={() => setBayId(bay.id)}
|
onChange={() => setBayId(bay.id)}
|
||||||
|
className="size-4 accent-amber-400"
|
||||||
/>
|
/>
|
||||||
{locationName(bay.locationId)}
|
{sprite && <Art sprite={sprite} className="size-8" />}
|
||||||
|
<span className="font-display font-semibold">{locationName(bay.locationId)}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
block
|
||||||
disabled={bayId === null || props.pending}
|
disabled={bayId === null || props.pending}
|
||||||
onClick={() => bayId && props.onBuy(bayId)}
|
onClick={() => bayId && props.onBuy(bayId)}
|
||||||
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-40"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
Buy for {formatCredits(props.model.price)}
|
Buy for {formatCredits(props.model.price)}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="secondary" block onClick={props.onClose} className="mt-2">
|
||||||
type="button"
|
|
||||||
onClick={props.onClose}
|
|
||||||
className="mt-2 w-full rounded-lg border border-slate-700 py-2"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Sheet>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,3 +184,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde
|
||||||
- [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).
|
- [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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue