Step 10E.7: pilot cards with skills, XP and duty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d5ea7cb48
commit
45a21a8684
|
|
@ -11,6 +11,7 @@ import {
|
||||||
shipModelName,
|
shipModelName,
|
||||||
} from "../format";
|
} from "../format";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Button, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
|
||||||
|
|
||||||
/** XP needed for the pilot's current and next level, or null at max level. */
|
/** XP needed for the pilot's current and next level, or null at max level. */
|
||||||
export function levelProgress(xp: number, level: number) {
|
export function levelProgress(xp: number, level: number) {
|
||||||
|
|
@ -25,20 +26,20 @@ export function levelProgress(xp: number, level: number) {
|
||||||
export function Pilots() {
|
export function Pilots() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 p-4">
|
<Screen className="max-w-4xl">
|
||||||
<h1 className="text-xl font-semibold">Pilots</h1>
|
<ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
|
||||||
<HirePilot />
|
<HirePilot />
|
||||||
{!state.pilots.some((p) => !p.isAvatar) && (
|
{!state.pilots.some((p) => !p.isAvatar) && (
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-ink-400">
|
||||||
No hired pilots yet. Hire one at a bay to crew another ship.
|
No hired pilots yet. Hire one at a bay to crew another ship.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
{state.pilots.map((pilot) => (
|
{state.pilots.map((pilot) => (
|
||||||
<PilotCard key={pilot.id} pilot={pilot} />
|
<PilotCard key={pilot.id} pilot={pilot} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,18 +51,23 @@ function HirePilot() {
|
||||||
const hasBay = state.bays.some((b) => b.locationId === bay);
|
const hasBay = state.bays.some((b) => b.locationId === bay);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-800 p-3 text-sm">
|
<Panel feature tone="amber" className="text-sm">
|
||||||
{state.bays.length === 0 ? (
|
{state.bays.length === 0 ? (
|
||||||
<p className="text-slate-400">Lease a bay to hire pilots there.</p>
|
<p className="text-ink-300">Lease a bay to hire pilots there.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/40">
|
||||||
|
<Icon name="hire" className="size-5" />
|
||||||
|
</span>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<span className="text-slate-400">Hire at</span>
|
<span className="font-display text-xs tracking-wider text-ink-300 uppercase">
|
||||||
|
Hire at
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
aria-label="Hire at"
|
aria-label="Hire at"
|
||||||
value={bay}
|
value={bay}
|
||||||
onChange={(e) => setBay(e.target.value)}
|
onChange={(e) => setBay(e.target.value)}
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
className="min-h-11 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display text-sm focus:border-amber-400 focus:outline-none"
|
||||||
>
|
>
|
||||||
{state.bays.map((b) => (
|
{state.bays.map((b) => (
|
||||||
<option key={b.id} value={b.locationId}>
|
<option key={b.id} value={b.locationId}>
|
||||||
|
|
@ -70,22 +76,21 @@ function HirePilot() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
disabled={!hasBay || state.company.credits < cost || hire.isPending}
|
disabled={!hasBay || state.company.credits < cost || hire.isPending}
|
||||||
onClick={() => hire.mutate({ locationId: bay })}
|
onClick={() => hire.mutate({ locationId: bay })}
|
||||||
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
|
className="ml-auto"
|
||||||
>
|
>
|
||||||
Hire pilot ({formatCredits(cost)})
|
Hire pilot ({formatCredits(cost)})
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hire.isError && (
|
{hire.isError && (
|
||||||
<p role="alert" className="mt-2 text-red-400">
|
<p role="alert" className="mt-2 text-rose-300">
|
||||||
{errorMessage(hire.error)}
|
{errorMessage(hire.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,23 +101,60 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
|
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
|
||||||
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
|
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
|
||||||
|
|
||||||
|
const initials = pilot.name
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded-lg border border-slate-800 p-3 text-sm">
|
<Panel as="li" tone={pilot.resting ? "muted" : "default"} className="flex flex-col text-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`relative grid size-14 shrink-0 place-items-center rounded-full font-display text-lg font-bold ring-2 ${
|
||||||
|
pilot.isAvatar
|
||||||
|
? "bg-amber-500/15 text-amber-200 ring-amber-400/70 shadow-glow-amber"
|
||||||
|
: "bg-cyan-500/10 text-cyan-200 ring-cyan-400/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
<span className="absolute -right-1 -bottom-1 grid size-6 place-items-center rounded-full bg-space-950 font-display text-[11px] text-ink-50 ring-2 ring-space-600">
|
||||||
|
{pilot.level}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="font-medium">
|
<span className="truncate font-display text-lg font-bold tracking-wide">
|
||||||
{pilot.name}
|
{pilot.name}
|
||||||
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
|
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-400">Level {pilot.level}</span>
|
<span className="font-display text-xs tracking-wider whitespace-nowrap text-ink-300 uppercase">
|
||||||
|
Level {pilot.level}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-ink-400">
|
||||||
<div className="mt-1 text-slate-400">
|
|
||||||
{progress
|
{progress
|
||||||
? `${progress.xp} / ${progress.needed} XP to next level`
|
? `${progress.xp} / ${progress.needed} XP to next level`
|
||||||
: `${pilot.xp} XP · max level`}
|
: `${pilot.xp} XP · max level`}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-slate-300">
|
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
|
||||||
{pilot.status === "in-flight" ? "In flight" : `At ${locationName(pilot.locationId ?? "")}`}
|
<div
|
||||||
|
className="h-full rounded-full bg-linear-to-r from-violet-500 to-fuchsia-300"
|
||||||
|
style={{ width: `${progress ? (progress.xp / progress.needed) * 100 : 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-ink-200">
|
||||||
|
{pilot.status === "in-flight" ? (
|
||||||
|
<Pill tone="cyan" icon="send" className="mr-1.5">
|
||||||
|
In flight
|
||||||
|
</Pill>
|
||||||
|
) : (
|
||||||
|
`At ${locationName(pilot.locationId ?? "")}`
|
||||||
|
)}
|
||||||
{" · "}
|
{" · "}
|
||||||
{lockedToLoaner
|
{lockedToLoaner
|
||||||
? "Flying the company loaner"
|
? "Flying the company loaner"
|
||||||
|
|
@ -121,29 +163,41 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
: "No ship"}
|
: "No ship"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
|
||||||
<div className="text-xs text-slate-400">
|
<div
|
||||||
|
className={`font-display text-[11px] tracking-[0.14em] uppercase ${pilot.skillPointsAvailable > 0 ? "text-amber-300" : "text-ink-400"}`}
|
||||||
|
>
|
||||||
Skill points:{" "}
|
Skill points:{" "}
|
||||||
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
|
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-1 grid grid-cols-2 gap-1">
|
<ul className="mt-2 grid grid-cols-2 gap-x-3 gap-y-2">
|
||||||
{content.skills.map((skill) => {
|
{content.skills.map((skill) => {
|
||||||
const level = pilot.skills[skill.id] ?? 0;
|
const level = pilot.skills[skill.id] ?? 0;
|
||||||
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
|
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
|
||||||
return (
|
return (
|
||||||
<li key={skill.id} className="flex items-center justify-between gap-1">
|
<li key={skill.id} className="flex items-center justify-between gap-1">
|
||||||
<span>
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-xs text-ink-200">
|
||||||
{skill.name} {level}/{skill.maxLevel}
|
{skill.name} {level}/{skill.maxLevel}
|
||||||
</span>
|
</span>
|
||||||
|
<span aria-hidden="true" className="mt-0.5 flex gap-0.5">
|
||||||
|
{Array.from({ length: skill.maxLevel }, (_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 w-3 rounded-sm ${i < level ? "bg-cyan-300 shadow-[0_0_4px] shadow-cyan-300/70" : "bg-space-700"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
{canRaise && (
|
{canRaise && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Raise ${skill.name} for ${pilot.name}`}
|
aria-label={`Raise ${skill.name} for ${pilot.name}`}
|
||||||
disabled={spend.isPending}
|
disabled={spend.isPending}
|
||||||
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
|
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
|
||||||
className="rounded border border-amber-500 px-1.5 text-amber-300"
|
className="grid size-8 shrink-0 animate-glow place-items-center rounded-lg border border-amber-400/70 bg-amber-500/15 text-amber-200 transition active:scale-95"
|
||||||
>
|
>
|
||||||
+
|
<Icon name="plus" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -151,7 +205,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{spend.isError && (
|
{spend.isError && (
|
||||||
<p role="alert" className="mt-1 text-red-400">
|
<p role="alert" className="mt-1 text-rose-300">
|
||||||
{errorMessage(spend.error)}
|
{errorMessage(spend.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -162,7 +216,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
|
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
|
||||||
<AssignShip pilot={pilot} />
|
<AssignShip pilot={pilot} />
|
||||||
)}
|
)}
|
||||||
</li>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,75 +233,68 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
|
||||||
|
|
||||||
if (pilot.resting && pilot.restEndsAt !== null) {
|
if (pilot.resting && pilot.restEndsAt !== null) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 text-sky-300" aria-label={`${pilot.name} rest`}>
|
<div
|
||||||
|
className="mt-3 flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-950/40 px-3 py-2 text-sky-200"
|
||||||
|
aria-label={`${pilot.name} rest`}
|
||||||
|
>
|
||||||
|
<Icon name="rest" className="size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
|
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
|
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
|
||||||
return (
|
return (
|
||||||
<div className="mt-2">
|
<div className="mt-3">
|
||||||
<div className="flex items-baseline justify-between text-xs text-slate-400">
|
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
<span>Duty</span>
|
<span>Duty</span>
|
||||||
<span>
|
<span className="tracking-normal normal-case">
|
||||||
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
|
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Meter
|
||||||
role="progressbar"
|
label={`${pilot.name} duty`}
|
||||||
aria-label={`${pilot.name} duty`}
|
value={used}
|
||||||
aria-valuemin={0}
|
max={pilot.dutyLimit}
|
||||||
aria-valuemax={pilot.dutyLimit}
|
tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
|
||||||
aria-valuenow={used}
|
size="sm"
|
||||||
className="mt-1 h-1.5 overflow-hidden rounded bg-slate-800"
|
className="mt-1"
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full bg-amber-400"
|
|
||||||
style={{ width: `${(used / pilot.dutyLimit) * 100}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{canRest && pilot.status === "idle" && !confirming && (
|
{canRest && pilot.status === "idle" && !confirming && (
|
||||||
<button
|
<Button variant="secondary" size="sm" onClick={() => setConfirming(true)} className="mt-3">
|
||||||
type="button"
|
<Icon name="rest" className="size-4" />
|
||||||
onClick={() => setConfirming(true)}
|
|
||||||
className="mt-2 rounded-md border border-slate-700 px-3 py-1"
|
|
||||||
>
|
|
||||||
Rest now
|
Rest now
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`Rest ${pilot.name}?`}
|
aria-label={`Rest ${pilot.name}?`}
|
||||||
className="mt-2 rounded-md bg-slate-900 p-2"
|
className="mt-3 rounded-lg border border-sky-400/30 bg-sky-950/40 p-3"
|
||||||
>
|
>
|
||||||
<p className="text-slate-300">
|
<p className="text-ink-200">
|
||||||
{pilot.name} rests for {formatDuration(restRealMs())} and can't fly until then.
|
{pilot.name} rests for {formatDuration(restRealMs())} and can't fly until then.
|
||||||
Rest always lasts at least one board refresh, so they wake to new contracts.
|
Rest always lasts at least one board refresh, so they wake to new contracts.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="sm"
|
||||||
disabled={rest.isPending}
|
disabled={rest.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) })
|
rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) })
|
||||||
}
|
}
|
||||||
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
|
|
||||||
>
|
>
|
||||||
Rest {pilot.name}
|
Rest {pilot.name}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="secondary" size="sm" onClick={() => setConfirming(false)}>
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirming(false)}
|
|
||||||
className="rounded-md border border-slate-700 px-3 py-1"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rest.isError && (
|
{rest.isError && (
|
||||||
<p role="alert" className="mt-1 text-red-400">
|
<p role="alert" className="mt-1 text-rose-300">
|
||||||
{errorMessage(rest.error)}
|
{errorMessage(rest.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -266,18 +313,18 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
|
||||||
|
|
||||||
if (ships.length === 0) {
|
if (ships.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="mt-2 text-xs text-slate-400">
|
<p className="mt-3 text-xs text-ink-400">
|
||||||
No uncrewed ships at {locationName(pilot.locationId ?? "")}.
|
No uncrewed ships at {locationName(pilot.locationId ?? "")}.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
aria-label={`Assign ${pilot.name} to`}
|
aria-label={`Assign ${pilot.name} to`}
|
||||||
value={shipId}
|
value={shipId}
|
||||||
onChange={(e) => setShipId(e.target.value)}
|
onChange={(e) => setShipId(e.target.value)}
|
||||||
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
className="min-w-0 flex-1 min-h-11 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display text-sm focus:border-amber-400 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">Choose a ship…</option>
|
<option value="">Choose a ship…</option>
|
||||||
{ships.map((s) => (
|
{ships.map((s) => (
|
||||||
|
|
@ -286,16 +333,15 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
disabled={!shipId || assign.isPending}
|
disabled={!shipId || assign.isPending}
|
||||||
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
|
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
|
||||||
className="rounded-md border border-slate-700 px-3 py-1 disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
Assign
|
Assign
|
||||||
</button>
|
</Button>
|
||||||
{assign.isError && (
|
{assign.isError && (
|
||||||
<p role="alert" className="text-red-400">
|
<p role="alert" className="text-rose-300">
|
||||||
{errorMessage(assign.error)}
|
{errorMessage(assign.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -185,3 +185,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde
|
||||||
- [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.
|
- [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
||||||
|
- [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue