Compare commits
No commits in common. "81e71e843640dcc62b7de9a4785d9c63856c6b68" and "7d8eb3fcfe3114e2afa7a470b5b071a69a128faa" have entirely different histories.
81e71e8436
...
7d8eb3fcfe
51
DECISIONS.md
|
|
@ -823,54 +823,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
|
||||
- **The CR's manual check was scripted** against a live dev server with the test clock (scratchpad `cr004-smoke.mjs`): mandatory rest after the sixth Earth↔Moon trip, `pilot-resting` while resting, and a different board on waking. The user's own check on a device is still the review gate.
|
||||
- **Observed for Phase 12 balance (not changed here):** a board's only guaranteed Courier job is used by the first trip from that location in a period, so later trips in the same period are usually deadheads. Resting, which wakes the pilot to a new board, is now the natural answer, which is what the CR intends; the size of that effect is for the balance pass.
|
||||
|
||||
## CR-005 — UI Overhaul (Phase 10E)
|
||||
|
||||
- **Change request CR-005 is stored in `memory-bank/change-requests/`** and inserted as Phase 10E, after Phase 10D and before Phase 11. Its decisions are plan §0.5.47–0.5.51.
|
||||
- **Requested by the user** ("the UI is very simple, has small text buttons on the bottom of the screen, and isn't very eye catching as a game"). Direction chosen: a "freight terminal HUD" that reuses the CR-003 art, so no new art is needed.
|
||||
|
||||
## Step 10E.1
|
||||
|
||||
- **Fonts via `@fontsource`, latin subset only, weights 500–700 (Chakra Petch) and 400–600 (Barlow).** Self-hosting keeps the game working offline for the Phase 11 PWA; Google Fonts would not.
|
||||
- **The backdrop is `body::before` (fixed, `z-index: -1`)**, so it never scrolls and screens no longer paint their own background. The layout's `bg-slate-950` was removed so it shows through.
|
||||
|
||||
## Step 10E.2
|
||||
|
||||
- **No component library.** The kit is a handful of small Tailwind components in `src/ui/`; a dependency like Radix or shadcn would be more than the game needs.
|
||||
- **Icons are hand-drawn inline SVG paths** (`ui/icons.tsx`), not an icon package: 28 icons, no network, `currentColor`, always `aria-hidden`.
|
||||
- **`Sheet` renders the title itself** so every dialog gets the same eyebrow/title/subtitle layout and the `aria-labelledby` wiring cannot be forgotten.
|
||||
- **`Art` uses the CR-003 generated sprites directly** (`/assets/map-gen/<sprite>@1x|2x.png`) through `ART_BASE_URL`, so screens and map share one art pipeline.
|
||||
|
||||
## Step 10E.3
|
||||
|
||||
- **Jobs badge during the tutorial is 1 until a run is in flight**: the loaner isn't a ship in state, but "go fly a run" is the one thing the player should do next.
|
||||
- **The desktop rail is 96 px with labels under icons**, not a wide sidebar, so the map and screens keep the room. The wordmark hides below 420 px wide so the credits and clock chips fit on small phones.
|
||||
- **Screenshot stack:** a scratchpad Playwright harness (playwright-core driving local Chrome) seeds a tutorial save and a mid-game save through the API on a separate database (`deadhead_shots`, API 3125, web 5199), as in Step 10C.
|
||||
|
||||
## Step 10E.4
|
||||
|
||||
- **"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.
|
||||
- **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".
|
||||
|
||||
## 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.
|
||||
|
||||
## Step 10E.9
|
||||
|
||||
- **Final screenshots are palette-quantized** (sharp `palette: true`, quality 90): 24 shots in 6 MB instead of 18 MB, with no visible banding in the art.
|
||||
- **The 500 KB chunk warning is accepted for now.** The UI kit and screens added about 27 KB to the main chunk; route-level code splitting (like the map's) can take it back under when the PWA work in Phase 11 looks at load size.
|
||||
|
||||
## Config change: shorter board period and rest
|
||||
|
||||
- **`contractBoardPeriodRealMinutes` 60 → 10 and `restGameHours` 60 → 10** (user request, after Phase 10D). At 60× compression a rest is now 10 real minutes, exactly one board period, so decision 0.5.46 still holds and its content test (rest in real time ≥ board period) is unchanged and passing.
|
||||
- **Effects:** boards refresh every 10 real minutes and every contract expires at the end of its period, so offers turn over six times as fast. A rested pilot is back in 10 minutes instead of an hour. This also resolves the earlier note that the CR-004 example expected a short rest.
|
||||
- **Two tests assumed a 60-minute period** and now derive their timing from config instead: the server's `testContract` helper (expiry = end of the board period) and the board-refresh countdown test. No game code changed.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { content } from "@deadhead/content";
|
|||
import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema";
|
||||
import {
|
||||
boardPeriodIndex,
|
||||
boardPeriodMs,
|
||||
createDispatchRecord,
|
||||
createRepositionRecord,
|
||||
type Contract,
|
||||
|
|
@ -106,8 +105,7 @@ export function testContract(overrides: Partial<Contract> = {}): Contract {
|
|||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: TEST_NOW,
|
||||
// Contracts expire at the end of their board period (TEST_NOW is a period start).
|
||||
expiresAt: TEST_NOW + boardPeriodMs(content.config),
|
||||
expiresAt: TEST_NOW + 3_600_000,
|
||||
firstContact: false,
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@
|
|||
"@deadhead/content": "workspace:*",
|
||||
"@deadhead/schema": "workspace:*",
|
||||
"@deadhead/sim": "workspace:*",
|
||||
"@fontsource/barlow": "^5.3.0",
|
||||
"@fontsource/chakra-petch": "^5.3.0",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"pixi.js": "^8.22.0",
|
||||
"react": "^19.3.0",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { startClockTicker, syncClock } from "./clock/clock";
|
|||
import { AwaySummary } from "./components/AwaySummary";
|
||||
import { DealModal } from "./components/DealModal";
|
||||
import { formatCredits } from "./format";
|
||||
import { tabAttention } from "./layout/attention";
|
||||
import { Layout } from "./layout/Layout";
|
||||
import { SCREENS } from "./routes";
|
||||
import { Contracts } from "./screens/Contracts";
|
||||
|
|
@ -16,7 +15,6 @@ import { Market } from "./screens/Market";
|
|||
import { Pilots } from "./screens/Pilots";
|
||||
import { StartScreen } from "./screens/StartScreen";
|
||||
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||
import { Button } from "./ui";
|
||||
|
||||
// The map pulls in PixiJS; load it only when the Map tab is opened.
|
||||
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
|
||||
|
|
@ -24,7 +22,7 @@ const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ defaul
|
|||
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
||||
"/": <HQ />,
|
||||
"/map": (
|
||||
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
|
||||
<Suspense fallback={<p className="p-4 text-slate-400">Loading map…</p>}>
|
||||
<MapScreen />
|
||||
</Suspense>
|
||||
),
|
||||
|
|
@ -54,20 +52,21 @@ export function App() {
|
|||
const retry = () => void state.refetch();
|
||||
|
||||
if (state.isPending) {
|
||||
return (
|
||||
<p className="grid min-h-dvh place-items-center font-display tracking-[0.3em] text-ink-400 uppercase">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
return <p className="p-6 text-slate-400">Loading…</p>;
|
||||
}
|
||||
if (state.data === undefined) {
|
||||
// The first load failed: nothing to show yet.
|
||||
return (
|
||||
<div role="alert" className="grid min-h-dvh place-content-center gap-4 p-6 text-center">
|
||||
<p className="font-display text-lg text-rose-300">Could not reach the server.</p>
|
||||
<Button onClick={retry} disabled={state.isFetching}>
|
||||
<div role="alert" className="space-y-3 p-6">
|
||||
<p className="text-red-400">Could not reach the server.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
disabled={state.isFetching}
|
||||
className="rounded-lg bg-amber-500 px-4 py-2 font-semibold text-slate-950 disabled:opacity-60"
|
||||
>
|
||||
{state.isFetching ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -77,12 +76,12 @@ export function App() {
|
|||
const banner = state.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-2 border-b border-rose-500/40 bg-rose-950/80 px-4 py-2 text-sm text-rose-200 backdrop-blur"
|
||||
className="flex items-center justify-between gap-2 bg-red-950 px-4 py-2 text-sm text-red-200"
|
||||
>
|
||||
<span>Offline — showing last update.</span>
|
||||
<Button variant="danger" size="sm" onClick={retry}>
|
||||
<button type="button" onClick={retry} className="underline">
|
||||
Retry
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
|
@ -90,13 +89,7 @@ export function App() {
|
|||
<>
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<Layout
|
||||
credits={formatCredits(state.data.company.credits)}
|
||||
banner={banner}
|
||||
attention={tabAttention(state.data)}
|
||||
/>
|
||||
}
|
||||
element={<Layout credits={formatCredits(state.data.company.credits)} banner={banner} />}
|
||||
>
|
||||
{SCREENS.map((screen) => (
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useNavigate } from "react-router";
|
|||
import { formatCredits, locationName } from "../format";
|
||||
import { formatLocalTime } from "../screens/DispatchSheet";
|
||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { Button, Icon, Sheet } from "../ui";
|
||||
|
||||
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */
|
||||
export function AwaySummary() {
|
||||
|
|
@ -32,91 +31,87 @@ export function AwaySummary() {
|
|||
};
|
||||
|
||||
return (
|
||||
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away">
|
||||
<ul className="mt-4 space-y-3">
|
||||
{summaries.map((s) => (
|
||||
<li
|
||||
key={s.dispatchId}
|
||||
className="rounded-xl border border-space-600 bg-space-950/50 p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-display font-bold tracking-wide">
|
||||
{shipName(s.shipId, s.tutorial)}
|
||||
</span>
|
||||
<span className="text-ink-400">
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 p-4 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="away-title"
|
||||
className="max-h-[80dvh] w-full max-w-md overflow-y-auto rounded-xl border border-slate-700 bg-slate-900 p-4"
|
||||
>
|
||||
<h2 id="away-title" className="text-lg font-semibold">
|
||||
While you were away
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{summaries.map((s) => (
|
||||
<li key={s.dispatchId} className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="font-medium">{shipName(s.shipId, s.tutorial)}</div>
|
||||
<div className="text-slate-400">
|
||||
{locationName(s.originId)} → {locationName(s.destinationId)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-4 font-display text-lg font-bold tabular-nums">
|
||||
<span className={s.credits >= 0 ? "text-emerald-300" : "text-rose-300"}>
|
||||
{s.credits >= 0 ? "+" : ""}
|
||||
{formatCredits(s.credits)}
|
||||
</span>
|
||||
<span className="text-violet-300">+{s.pilotXp} XP</span>
|
||||
</div>
|
||||
{s.loanRepayment > 0 && (
|
||||
<div className="mt-1 text-ink-400">
|
||||
{formatCredits(s.loanRepayment)} went to the lender
|
||||
</div>
|
||||
)}
|
||||
{s.scriptedId && (
|
||||
<p className="mt-1 text-fuchsia-200 italic">
|
||||
{
|
||||
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
|
||||
?.flavorMessage
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{s.discoveredLocationId && (
|
||||
<div className="mt-2 flex items-center gap-1.5 font-display font-semibold text-amber-300">
|
||||
<Icon name="star" className="size-4" />
|
||||
Discovered {locationName(s.discoveredLocationId)}!
|
||||
<div className="mt-1 flex gap-4 tabular-nums">
|
||||
<span className={s.credits >= 0 ? "text-emerald-400" : "text-red-400"}>
|
||||
{s.credits >= 0 ? "+" : ""}
|
||||
{formatCredits(s.credits)}
|
||||
</span>
|
||||
<span>+{s.pilotXp} XP</span>
|
||||
</div>
|
||||
)}
|
||||
{s.restEndsAt !== null && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-sky-300">
|
||||
<Icon name="rest" className="size-4 shrink-0" />
|
||||
<span>
|
||||
{s.loanRepayment > 0 && (
|
||||
<div className="mt-1 text-slate-400">
|
||||
{formatCredits(s.loanRepayment)} went to the lender
|
||||
</div>
|
||||
)}
|
||||
{s.scriptedId && (
|
||||
<p className="mt-1 italic text-fuchsia-200">
|
||||
{
|
||||
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
|
||||
?.flavorMessage
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{s.discoveredLocationId && (
|
||||
<div className="mt-1 text-amber-300">
|
||||
Discovered {locationName(s.discoveredLocationId)}!
|
||||
</div>
|
||||
)}
|
||||
{s.restEndsAt !== null && (
|
||||
<div className="mt-1 text-sky-300">
|
||||
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting
|
||||
until {formatLocalTime(s.restEndsAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{readyGoals.length > 0 && (
|
||||
<div className="mt-3 rounded-xl border border-emerald-400/40 bg-emerald-950/40 p-3 text-sm shadow-glow-emerald">
|
||||
<h3 className="flex items-center gap-1.5 font-display font-bold text-emerald-300">
|
||||
<Icon name="target" className="size-4" />
|
||||
Goals ready to claim
|
||||
</h3>
|
||||
<ul aria-label="Ready goals" className="mt-1 space-y-0.5">
|
||||
{readyGoals.map((goal) => (
|
||||
<li key={goal.id}>
|
||||
{goal.title}{" "}
|
||||
<span className="font-display text-amber-300/90">
|
||||
+{formatCredits(goal.reward)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
void navigate({ pathname: "/", hash: "#goals" });
|
||||
}}
|
||||
className="mt-3"
|
||||
>
|
||||
Claim on HQ
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button block onClick={dismiss} className="mt-4">
|
||||
Got it
|
||||
</Button>
|
||||
</Sheet>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{readyGoals.length > 0 && (
|
||||
<div className="mt-3 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
|
||||
<h3 className="font-semibold text-emerald-300">Goals ready to claim</h3>
|
||||
<ul aria-label="Ready goals" className="mt-1 space-y-0.5">
|
||||
{readyGoals.map((goal) => (
|
||||
<li key={goal.id}>
|
||||
{goal.title} <span className="text-slate-400">+{formatCredits(goal.reward)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
void navigate({ pathname: "/", hash: "#goals" });
|
||||
}}
|
||||
className="mt-2 font-medium text-emerald-300 underline"
|
||||
>
|
||||
Claim on HQ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useApi } from "../api/context";
|
|||
import { formatCredits } from "../format";
|
||||
import { errorMessage, useLoadedPlayer } from "../state/actions";
|
||||
import { PLAYER_STATE_KEY } from "../state/player";
|
||||
import { Art, Button, Sheet } from "../ui";
|
||||
|
||||
/**
|
||||
* The one-time deal (decision 0.5.30): shown when the server says it is
|
||||
|
|
@ -31,53 +30,46 @@ export function DealModal() {
|
|||
const courier = content.shipModel("courier")!;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
titleId="deal-title"
|
||||
eyebrow="Incoming transmission"
|
||||
title="An offer from Halvorsen"
|
||||
tone="amber"
|
||||
layer="z-30"
|
||||
>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Art sprite={courier.sprite} className="size-20 shrink-0 -rotate-12" />
|
||||
<p className="text-sm text-ink-200">
|
||||
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="deal-title"
|
||||
className="w-full max-w-md rounded-xl border border-amber-500/50 bg-slate-900 p-4"
|
||||
>
|
||||
<h2 id="deal-title" className="text-lg font-semibold text-amber-300">
|
||||
An offer from Halvorsen
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
Old Halvorsen is retiring. He'll sell you his Courier and his Earth bay. Pay him back
|
||||
out of every job.
|
||||
</p>
|
||||
</div>
|
||||
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-space-950/60 p-3 text-sm ring-1 ring-amber-400/25">
|
||||
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||
You get
|
||||
</dt>
|
||||
<dd>A {courier.name} and a bay at Earth</dd>
|
||||
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Loan</dt>
|
||||
<dd
|
||||
aria-label="Loan amount"
|
||||
className="font-display font-semibold text-amber-200 tabular-nums"
|
||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
||||
<dt className="text-slate-400">You get</dt>
|
||||
<dd>A {courier.name} and a bay at Earth</dd>
|
||||
<dt className="text-slate-400">Loan</dt>
|
||||
<dd aria-label="Loan amount" className="tabular-nums">
|
||||
{formatCredits(dealLoanAmount)}
|
||||
</dd>
|
||||
<dt className="text-slate-400">Repayment</dt>
|
||||
<dd aria-label="Repayment share">
|
||||
{Math.round(loanRepaymentShare * 100)}% of every payout until it's paid off
|
||||
</dd>
|
||||
</dl>
|
||||
{accept.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-400">
|
||||
{errorMessage(accept.error)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => accept.mutate()}
|
||||
disabled={accept.isPending}
|
||||
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-60"
|
||||
>
|
||||
{formatCredits(dealLoanAmount)}
|
||||
</dd>
|
||||
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||
Repayment
|
||||
</dt>
|
||||
<dd aria-label="Repayment share">
|
||||
{Math.round(loanRepaymentShare * 100)}% of every payout until it's paid off
|
||||
</dd>
|
||||
</dl>
|
||||
{accept.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-rose-300">
|
||||
{errorMessage(accept.error)}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => accept.mutate()}
|
||||
disabled={accept.isPending}
|
||||
className="mt-5"
|
||||
>
|
||||
Shake on it
|
||||
</Button>
|
||||
</Sheet>
|
||||
Shake on it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { formatCredits } from "../format";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { Button, Icon, Panel } from "../ui";
|
||||
|
||||
/** How many unclaimed goals HQ shows at once (Step 10A.15). */
|
||||
export const GOALS_SHOWN = 3;
|
||||
|
|
@ -14,61 +13,46 @@ export function GoalsCard() {
|
|||
if (next.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Panel as="section" feature id="goals" aria-labelledby="goals-title">
|
||||
<h2 id="goals-title" className="flex items-center gap-2 font-display text-lg font-bold">
|
||||
<Icon name="target" className="size-5 text-amber-300" />
|
||||
<section
|
||||
id="goals"
|
||||
aria-labelledby="goals-title"
|
||||
className="rounded-lg border border-slate-800 p-3"
|
||||
>
|
||||
<h2 id="goals-title" className="font-semibold">
|
||||
Goals
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<ul className="mt-2 space-y-2">
|
||||
{next.map((goal) => {
|
||||
const details = content.onboardingGoal(goal.id);
|
||||
if (!details) return null;
|
||||
const ready = goal.status === "ready";
|
||||
return (
|
||||
<li
|
||||
key={goal.id}
|
||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
ready
|
||||
? "border-emerald-400/50 bg-emerald-500/10 shadow-glow-emerald"
|
||||
: "border-space-700 bg-space-950/40"
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid size-5 shrink-0 place-items-center rounded-full ring-1 ${
|
||||
ready ? "bg-emerald-400 text-space-950 ring-emerald-300" : "ring-space-500"
|
||||
}`}
|
||||
>
|
||||
{ready && <Icon name="check" className="size-3.5" />}
|
||||
</span>
|
||||
<span className={ready ? "text-emerald-200" : "text-ink-200"}>
|
||||
{details.title}
|
||||
<span className="ml-2 font-display text-xs font-semibold whitespace-nowrap text-amber-300/90">
|
||||
+{formatCredits(details.reward)}
|
||||
</span>
|
||||
<li key={goal.id} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className={ready ? "text-emerald-300" : "text-slate-300"}>
|
||||
{details.title}
|
||||
<span className="ml-2 text-xs text-slate-400">
|
||||
+{formatCredits(details.reward)}
|
||||
</span>
|
||||
</span>
|
||||
{ready && (
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
<button
|
||||
type="button"
|
||||
disabled={claim.isPending}
|
||||
onClick={() => claim.mutate({ goalId: goal.id })}
|
||||
className="shrink-0 animate-glow"
|
||||
className="shrink-0 rounded-md bg-emerald-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Claim
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{claim.isError && (
|
||||
<p role="alert" className="mt-2 text-xs text-rose-300">
|
||||
<p role="alert" className="mt-2 text-xs text-red-400">
|
||||
{errorMessage(claim.error)}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { content } from "@deadhead/content";
|
|||
import { useState } from "react";
|
||||
import { formatCredits } from "../format";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { Button, Meter, Panel } from "../ui";
|
||||
|
||||
/** Why a pay-down amount cannot be sent, or null if it can. */
|
||||
export function payDownProblem(amount: number, credits: number, balance: number): string | null {
|
||||
|
|
@ -24,24 +23,28 @@ export function LoanCard() {
|
|||
const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<div className="rounded-lg border border-slate-700 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h2 className="font-display text-lg font-bold">Loan from Halvorsen</h2>
|
||||
<span
|
||||
aria-label="Loan balance"
|
||||
className="font-display font-semibold text-amber-200 tabular-nums"
|
||||
>
|
||||
<h2 className="font-semibold">Loan from Halvorsen</h2>
|
||||
<span aria-label="Loan balance" className="tabular-nums">
|
||||
{formatCredits(loanBalance)} left
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{Math.round(content.config.loanRepaymentShare * 100)}% of every payout goes to the lender
|
||||
until it's paid off.
|
||||
</p>
|
||||
<Meter label="Loan paid off" value={paidOff} tone="emerald" className="mt-3" />
|
||||
<p className="mt-1 text-right font-display text-xs text-emerald-300/80">
|
||||
{paidOff}% paid off
|
||||
</p>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Loan paid off"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={paidOff}
|
||||
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
|
||||
>
|
||||
<div className="h-full bg-emerald-400" style={{ width: `${paidOff}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-right text-xs text-slate-400">{paidOff}% paid off</p>
|
||||
|
||||
<form
|
||||
className="mt-2 flex items-center gap-2"
|
||||
|
|
@ -59,18 +62,22 @@ export function LoanCard() {
|
|||
placeholder="Amount"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="min-h-11 min-w-0 flex-1 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display tabular-nums placeholder:text-ink-500 focus:border-amber-400 focus:outline-none"
|
||||
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
||||
/>
|
||||
<Button type="submit" disabled={input === "" || problem !== null || repay.isPending}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={input === "" || problem !== null || repay.isPending}
|
||||
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Pay down
|
||||
</Button>
|
||||
</button>
|
||||
</form>
|
||||
{problem && <p className="mt-1 text-xs text-rose-300">{problem}</p>}
|
||||
{problem && <p className="mt-1 text-xs text-red-400">{problem}</p>}
|
||||
{repay.isError && (
|
||||
<p role="alert" className="mt-1 text-xs text-rose-300">
|
||||
<p role="alert" className="mt-1 text-xs text-red-400">
|
||||
{errorMessage(repay.error)}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { locationName } from "../format";
|
||||
import { Art, Icon } from "../ui";
|
||||
|
||||
/**
|
||||
* Origin art → flight line → destination art (CR-005). With `progress` (0–1)
|
||||
* the line fills and the ship's art rides along it. Decorative: the route is
|
||||
* always in text nearby, so the strip is hidden from screen readers.
|
||||
*/
|
||||
export function RouteStrip({
|
||||
originId,
|
||||
destinationId,
|
||||
progress,
|
||||
shipSprite,
|
||||
size = "md",
|
||||
}: {
|
||||
originId: string;
|
||||
destinationId: string;
|
||||
progress?: number;
|
||||
shipSprite?: string;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const percent = progress === undefined ? null : Math.max(0, Math.min(1, progress)) * 100;
|
||||
return (
|
||||
<div aria-hidden="true" className="flex items-center gap-1">
|
||||
<Stop id={originId} size={size} />
|
||||
<div className="relative h-8 flex-1">
|
||||
<div className="absolute inset-x-0 top-1/2 border-t-2 border-dashed border-cyan-400/35" />
|
||||
{percent === null ? (
|
||||
<Icon
|
||||
name="arrow"
|
||||
className="absolute top-1/2 left-1/2 size-4 -translate-1/2 rounded-full bg-space-900 text-cyan-300"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-1/2 left-0 h-0.5 -translate-y-1/2 rounded-full bg-linear-to-r from-cyan-500/40 to-cyan-300 shadow-glow-cyan"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<span
|
||||
className="absolute top-1/2 -translate-1/2 transition-[left] duration-1000"
|
||||
style={{ left: `${percent}%` }}
|
||||
>
|
||||
{shipSprite ? (
|
||||
<Art
|
||||
sprite={shipSprite}
|
||||
className="size-8 rotate-90 drop-shadow-[0_0_6px_rgb(34_211_238/0.7)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="block size-2.5 rounded-full bg-cyan-200 shadow-glow-cyan" />
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Stop id={destinationId} size={size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stop({ id, size }: { id: string; size: "sm" | "md" }) {
|
||||
const sprite = content.location(id)?.sprite;
|
||||
return (
|
||||
<div className={`flex shrink-0 flex-col items-center ${size === "sm" ? "w-12" : "w-16"}`}>
|
||||
<div
|
||||
className={`grid place-items-center ${size === "sm" ? "size-7" : "size-10"} rounded-full`}
|
||||
>
|
||||
{sprite ? (
|
||||
<Art
|
||||
sprite={sprite}
|
||||
className={`${size === "sm" ? "size-7" : "size-10"} drop-shadow-[0_0_8px_rgb(125_211_252/0.35)]`}
|
||||
/>
|
||||
) : (
|
||||
<span className="size-3 rounded-full bg-ink-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* CSS-drawn label: the route is already in text nearby, so keep it out of the DOM. */}
|
||||
<span
|
||||
data-label={locationName(id)}
|
||||
className="mt-0.5 max-w-full truncate font-display text-[10px] font-semibold tracking-wider text-ink-300 uppercase after:content-[attr(data-label)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,107 +1 @@
|
|||
@import "tailwindcss";
|
||||
@import "@fontsource/chakra-petch/latin-500.css";
|
||||
@import "@fontsource/chakra-petch/latin-600.css";
|
||||
@import "@fontsource/chakra-petch/latin-700.css";
|
||||
@import "@fontsource/barlow/latin-400.css";
|
||||
@import "@fontsource/barlow/latin-500.css";
|
||||
@import "@fontsource/barlow/latin-600.css";
|
||||
|
||||
/* "Freight terminal HUD" theme (CR-005, decisions 0.5.47–0.5.48). */
|
||||
@theme {
|
||||
--font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Chakra Petch", "Barlow", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/* Surfaces, darkest first. */
|
||||
--color-space-950: #03050d;
|
||||
--color-space-900: #080d1d;
|
||||
--color-space-850: #0c132a;
|
||||
--color-space-800: #111a36;
|
||||
--color-space-700: #1a2548;
|
||||
--color-space-600: #26345f;
|
||||
--color-space-500: #3a4a7c;
|
||||
|
||||
/* Text. */
|
||||
--color-ink-50: #f2f5ff;
|
||||
--color-ink-200: #c9d3ef;
|
||||
--color-ink-300: #a3afd3;
|
||||
--color-ink-400: #7e8bb3;
|
||||
--color-ink-500: #5c688f;
|
||||
|
||||
--shadow-glow-amber: 0 0 0 1px rgb(251 191 36 / 0.45), 0 0 22px -4px rgb(251 191 36 / 0.6);
|
||||
--shadow-glow-cyan: 0 0 0 1px rgb(34 211 238 / 0.4), 0 0 22px -4px rgb(34 211 238 / 0.55);
|
||||
--shadow-glow-emerald: 0 0 0 1px rgb(52 211 153 / 0.4), 0 0 22px -4px rgb(52 211 153 / 0.55);
|
||||
--shadow-panel: inset 0 1px 0 0 rgb(255 255 255 / 0.05), 0 12px 32px -12px rgb(0 0 0 / 0.8);
|
||||
|
||||
--animate-rise: rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
--animate-glow: glow 2.4s ease-in-out infinite;
|
||||
--animate-sheet: sheet 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
@keyframes sheet {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-space-950);
|
||||
color: var(--color-ink-50);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* Every screen sits in space: the map background, dimmed (decision 0.5.47). */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%, rgb(56 189 248 / 0.1), transparent 70%),
|
||||
linear-gradient(180deg, rgb(3 5 13 / 0.5), rgb(3 5 13 / 0.82)),
|
||||
url("/assets/map-gen/bg.webp") center / cover no-repeat,
|
||||
var(--color-space-950);
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-amber-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Motion is decoration only: switch it off when asked. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { GameClock } from "../clock/GameClock";
|
||||
import { SCREENS, type ScreenPath } from "../routes";
|
||||
import { Icon } from "../ui";
|
||||
import { SCREENS } from "../routes";
|
||||
|
||||
export interface LayoutProps {
|
||||
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
||||
|
|
@ -10,155 +9,44 @@ export interface LayoutProps {
|
|||
clock?: ReactNode;
|
||||
/** Shown under the header, e.g. the offline notice. */
|
||||
banner?: ReactNode;
|
||||
/** Attention badge counts per tab (decision 0.5.49). */
|
||||
attention?: Partial<Record<ScreenPath, number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HUD header with credits and game clock, the current screen, and icon
|
||||
* navigation: a bottom bar on phones, a left rail from `md` (CR-005).
|
||||
* `--header-h` and `--nav-h` let full-height screens (the map) fit exactly.
|
||||
*/
|
||||
export function Layout({
|
||||
credits = "—",
|
||||
clock = <GameClock />,
|
||||
banner,
|
||||
attention = {},
|
||||
}: LayoutProps) {
|
||||
/** Header with credits and game clock, the current screen, and a bottom tab bar. */
|
||||
export function Layout({ credits = "—", clock = <GameClock />, banner }: LayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col text-ink-50 [--header-h:3.5rem] [--nav-h:calc(4.25rem+env(safe-area-inset-bottom))] md:pl-24 md:[--nav-h:0px]">
|
||||
<header className="sticky top-0 z-10 flex h-(--header-h) items-center justify-between gap-2 border-b border-space-700/80 bg-space-950/80 px-3 backdrop-blur-xl sm:px-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LogoMark />
|
||||
<span className="hidden font-display text-lg font-bold tracking-[0.28em] min-[420px]:inline">
|
||||
DEADHEAD
|
||||
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2">
|
||||
<span className="truncate text-sm font-bold tracking-wide">DEADHEAD</span>
|
||||
<div className="flex shrink-0 items-center gap-3 text-sm tabular-nums">
|
||||
<span aria-label="Credits">{credits}</span>
|
||||
<span aria-label="Game clock" className="text-slate-400">
|
||||
{clock}
|
||||
</span>
|
||||
<span className="hidden font-display text-[10px] tracking-[0.3em] text-cyan-300/70 uppercase lg:inline">
|
||||
Space Freight
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-amber-400/45 bg-amber-500/10 px-2.5 py-1 text-amber-200 shadow-glow-amber">
|
||||
<Icon name="credits" className="size-4 text-amber-300" />
|
||||
<span aria-label="Credits" className="font-display text-sm font-bold tabular-nums">
|
||||
{credits}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-space-600 bg-space-900/80 px-2.5 py-1 text-ink-300">
|
||||
<Icon name="clock" className="size-4 text-cyan-300" />
|
||||
<span aria-label="Game clock" className="font-display text-xs tabular-nums sm:text-sm">
|
||||
{clock}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{banner}
|
||||
|
||||
<main className="flex-1 overflow-x-hidden pb-[calc(var(--nav-h)+1.5rem)]">
|
||||
<main className="flex-1 overflow-x-hidden pb-20">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-7 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-1 md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4"
|
||||
className="fixed inset-x-0 bottom-0 grid grid-cols-7 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 -top-px h-px bg-linear-to-r from-transparent via-amber-400/50 to-transparent md:hidden"
|
||||
/>
|
||||
{SCREENS.map((screen) => (
|
||||
<NavTab
|
||||
<NavLink
|
||||
key={screen.path}
|
||||
path={screen.path}
|
||||
label={screen.label}
|
||||
icon={screen.icon}
|
||||
badge={attention[screen.path] ?? 0}
|
||||
/>
|
||||
to={screen.path}
|
||||
end={screen.path === "/"}
|
||||
className={({ isActive }) =>
|
||||
`truncate px-1 py-3 text-center text-xs ${isActive ? "font-semibold text-amber-400" : "text-slate-400"}`
|
||||
}
|
||||
>
|
||||
{screen.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavTab({
|
||||
path,
|
||||
label,
|
||||
icon,
|
||||
badge,
|
||||
}: {
|
||||
path: ScreenPath;
|
||||
label: string;
|
||||
icon: (typeof SCREENS)[number]["icon"];
|
||||
badge: number;
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/"}
|
||||
className={({ isActive }) =>
|
||||
`group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-2 ${
|
||||
isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute top-0 h-0.5 w-8 rounded-full bg-amber-400 shadow-glow-amber transition-opacity md:top-1/2 md:left-0 md:h-8 md:w-0.5 md:-translate-y-1/2 ${
|
||||
isActive ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative grid size-10 place-items-center rounded-xl transition md:size-12 ${
|
||||
isActive
|
||||
? "bg-amber-500/15 shadow-glow-amber"
|
||||
: "group-hover:bg-white/5 group-active:scale-95"
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} className="size-6" />
|
||||
{badge > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid={`badge-${label}`}
|
||||
className="absolute -top-1 -right-1 grid h-4.5 min-w-4.5 animate-glow place-items-center rounded-full bg-rose-500 px-1 font-display text-[10px] font-bold text-white ring-2 ring-space-950"
|
||||
>
|
||||
{badge > 9 ? "9+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-display text-[10px] font-semibold tracking-wider uppercase sm:text-[11px]">
|
||||
{label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
/** The game's mark: an orbit ring around an arrowhead. */
|
||||
function LogoMark() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 32 32" className="size-8 shrink-0">
|
||||
<defs>
|
||||
<linearGradient id="logo-grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#fde68a" />
|
||||
<stop offset="1" stopColor="#f59e0b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="13"
|
||||
fill="none"
|
||||
stroke="url(#logo-grad)"
|
||||
strokeWidth="2.2"
|
||||
strokeDasharray="58 24"
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-50 16 16)"
|
||||
/>
|
||||
<circle cx="27.5" cy="9" r="1.8" fill="#67e8f9" />
|
||||
<path d="M16 7.5 22.5 22 16 18.5 9.5 22z" fill="url(#logo-grad)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import type { PlayerState } from "@deadhead/schema";
|
||||
import type { ScreenPath } from "../routes";
|
||||
|
||||
/**
|
||||
* Counts for the navigation's attention badges (decision 0.5.49): goals ready
|
||||
* to claim, ships ready for a job, and pilots with skill points to spend.
|
||||
*/
|
||||
export function tabAttention(state: PlayerState): Partial<Record<ScreenPath, number>> {
|
||||
const restingIds = new Set(state.pilots.filter((p) => p.resting).map((p) => p.id));
|
||||
const readyShips = state.tutorial
|
||||
? state.tutorial.runInFlight
|
||||
? 0
|
||||
: 1
|
||||
: state.ships.filter(
|
||||
(s) => s.status === "idle" && s.pilotId !== null && !restingIds.has(s.pilotId),
|
||||
).length;
|
||||
return {
|
||||
"/": state.goals.filter((g) => g.status === "ready").length,
|
||||
"/contracts": readyShips,
|
||||
"/pilots": state.pilots.filter((p) => p.skillPointsAvailable > 0).length,
|
||||
};
|
||||
}
|
||||
|
|
@ -157,10 +157,7 @@ export function MapCanvas({
|
|||
className="relative h-full w-full touch-none overflow-hidden"
|
||||
>
|
||||
{loading && (
|
||||
<p
|
||||
role="status"
|
||||
className="absolute inset-x-0 top-4 text-center font-display text-sm tracking-widest text-ink-400 uppercase"
|
||||
>
|
||||
<p role="status" className="absolute inset-x-0 top-4 text-center text-sm text-slate-400">
|
||||
Loading map…
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import type { IconName } from "./ui/icons";
|
||||
|
||||
/** The app's top-level screens, in navigation order. Labels are short to fit seven tabs. */
|
||||
/** The app's top-level screens, in bottom-navigation order. Labels are short to fit seven tabs. */
|
||||
export const SCREENS = [
|
||||
{ path: "/", title: "HQ", label: "HQ", icon: "hq" },
|
||||
{ path: "/map", title: "Map", label: "Map", icon: "map" },
|
||||
{ path: "/contracts", title: "Contracts", label: "Jobs", icon: "jobs" },
|
||||
{ path: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" },
|
||||
{ path: "/pilots", title: "Pilots", label: "Pilots", icon: "pilots" },
|
||||
{ path: "/market", title: "Market", label: "Shop", icon: "market" },
|
||||
{ path: "/locations", title: "Locations", label: "Places", icon: "places" },
|
||||
] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[];
|
||||
|
||||
export type ScreenPath = (typeof SCREENS)[number]["path"];
|
||||
{ path: "/", title: "HQ", label: "HQ" },
|
||||
{ path: "/map", title: "Map", label: "Map" },
|
||||
{ path: "/contracts", title: "Contracts", label: "Jobs" },
|
||||
{ path: "/fleet", title: "Fleet", label: "Fleet" },
|
||||
{ path: "/pilots", title: "Pilots", label: "Pilots" },
|
||||
{ path: "/market", title: "Market", label: "Shop" },
|
||||
{ path: "/locations", title: "Locations", label: "Places" },
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import { useState } from "react";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { Screen, ScreenHeader } from "../ui";
|
||||
import { LocationBoards } from "./LocationBoards";
|
||||
import { DispatchSheet } from "./DispatchSheet";
|
||||
import { TutorialBoard } from "./TutorialBoard";
|
||||
|
|
@ -11,14 +10,10 @@ export function Contracts() {
|
|||
const state = useLoadedPlayer();
|
||||
const [selected, setSelected] = useState<Contract | null>(null);
|
||||
return (
|
||||
<Screen className="max-w-4xl">
|
||||
<ScreenHeader
|
||||
title="Contracts"
|
||||
eyebrow={state.tutorial ? "Halvorsen Lunar Freight" : "Freight market"}
|
||||
icon="jobs"
|
||||
/>
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Contracts</h1>
|
||||
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
||||
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
|
||||
</Screen>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ import {
|
|||
import { requirementsText } from "./LocationBoards";
|
||||
import { location, shipModel, tripRealMs } from "../game";
|
||||
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. */
|
||||
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||
|
|
@ -109,8 +107,6 @@ export interface PairOption {
|
|||
shipId: string;
|
||||
shipName: string;
|
||||
modelName: string;
|
||||
/** The model's art (decision 0.5.50). */
|
||||
modelSprite: string;
|
||||
pilotId: string | null;
|
||||
pilotName: string | null;
|
||||
/** Why this pair cannot take the contract; empty when it can. */
|
||||
|
|
@ -190,7 +186,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
|||
shipId: ship.id,
|
||||
shipName: ship.name,
|
||||
modelName: model.name,
|
||||
modelSprite: model.sprite,
|
||||
pilotId: pilot?.id ?? null,
|
||||
pilotName: pilot?.name ?? null,
|
||||
problems,
|
||||
|
|
@ -228,129 +223,119 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
|||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
titleId="dispatch-title"
|
||||
eyebrow="Dispatch"
|
||||
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
|
||||
>
|
||||
<p aria-label="Job details" className="mt-1 text-sm text-ink-300">
|
||||
{cargoName(contract.cargoTypeId)}
|
||||
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
|
||||
{formatCredits(contract.payout)}
|
||||
{" · "}
|
||||
{requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<RouteStrip originId={contract.originId} destinationId={contract.destinationId} />
|
||||
</div>
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-ink-400">
|
||||
No idle ships at {locationName(contract.originId)}.
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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">
|
||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||
</h2>
|
||||
<p aria-label="Job details" className="text-sm text-slate-400">
|
||||
{cargoName(contract.cargoTypeId)}
|
||||
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
|
||||
{formatCredits(contract.payout)}
|
||||
{" · "}
|
||||
{requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{options.map((option) => {
|
||||
const blocked = option.problems.length > 0;
|
||||
const picked = chosen === option.shipId;
|
||||
return (
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-400">
|
||||
No idle ships at {locationName(contract.originId)}.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{options.map((option) => (
|
||||
<li key={option.shipId}>
|
||||
<label
|
||||
aria-disabled={blocked}
|
||||
className={`block rounded-xl border p-3 text-sm transition ${
|
||||
blocked
|
||||
? "border-space-700 bg-space-950/40 opacity-70"
|
||||
: picked
|
||||
? "cursor-pointer border-amber-400 bg-amber-500/10 shadow-glow-amber"
|
||||
: "cursor-pointer border-space-600 bg-space-900/60 hover:border-space-500"
|
||||
aria-disabled={option.problems.length > 0}
|
||||
className={`block rounded-lg border p-3 text-sm ${
|
||||
option.problems.length > 0
|
||||
? "border-slate-800 opacity-60"
|
||||
: chosen === option.shipId
|
||||
? "border-amber-400"
|
||||
: "border-slate-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="pair"
|
||||
value={option.shipId}
|
||||
disabled={blocked}
|
||||
checked={picked}
|
||||
disabled={option.problems.length > 0}
|
||||
checked={chosen === option.shipId}
|
||||
onChange={() => setChosen(option.shipId)}
|
||||
className="size-4 shrink-0 accent-amber-400"
|
||||
/>
|
||||
<Art sprite={option.modelSprite} className="size-10 shrink-0" />
|
||||
<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"}
|
||||
</span>
|
||||
<span className="font-medium">{option.shipName}</span>
|
||||
<span className="text-slate-400">
|
||||
{option.modelName} · {option.pilotName ?? "no pilot"}
|
||||
</span>
|
||||
</div>
|
||||
{blocked ? (
|
||||
<ul aria-label="Problems" className="mt-2 space-y-0.5 pl-1 text-rose-300">
|
||||
{option.problems.length > 0 ? (
|
||||
<ul aria-label="Problems" className="mt-1 list-disc pl-5 text-red-400">
|
||||
{option.problems.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex gap-1.5 before:text-rose-400 before:content-['✕']"
|
||||
>
|
||||
{p}
|
||||
</li>
|
||||
<li key={p}>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<Cell label="Trip" value={formatDuration(option.tripMs)} />
|
||||
<Cell label="Fuel" value={formatCredits(option.fuelCost)} />
|
||||
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
|
||||
<Cell
|
||||
label="ETA (game)"
|
||||
value={formatGameTime(realToGame(option.arrivalAt, content.config))}
|
||||
/>
|
||||
<Cell label="ETA (real)" value={formatLocalTime(option.arrivalAt)} />
|
||||
<dl className="mt-1 grid grid-cols-2 gap-x-3 text-slate-300">
|
||||
<dt>Trip</dt>
|
||||
<dd className="tabular-nums">{formatDuration(option.tripMs)}</dd>
|
||||
<dt>Fuel</dt>
|
||||
<dd className="tabular-nums">{formatCredits(option.fuelCost)}</dd>
|
||||
<dt>Net profit</dt>
|
||||
<dd className="tabular-nums">{formatCredits(option.netProfit)}</dd>
|
||||
<dt>ETA (game)</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatGameTime(realToGame(option.arrivalAt, content.config))}
|
||||
</dd>
|
||||
<dt>ETA (real)</dt>
|
||||
<dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd>
|
||||
{option.duty && (
|
||||
<Cell
|
||||
label="Duty after"
|
||||
value={`${formatGameHours(option.duty.after)} of ${formatGameHours(option.duty.limit)}`}
|
||||
/>
|
||||
<>
|
||||
<dt>Duty after</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatGameHours(option.duty.after)} of{" "}
|
||||
{formatGameHours(option.duty.limit)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
{!blocked && option.duty?.willRest && (
|
||||
<p className="mt-2 text-amber-300">Will need rest on arrival</p>
|
||||
{option.problems.length === 0 && option.duty?.willRest && (
|
||||
<p className="mt-1 text-amber-300">Will need rest on arrival</p>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{dispatch.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-rose-300">
|
||||
{errorMessage(dispatch.error)}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirm} disabled={!selected || dispatch.isPending} className="flex-1">
|
||||
Confirm
|
||||
</Button>
|
||||
{dispatch.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-400">
|
||||
{errorMessage(dispatch.error)}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-slate-700 py-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,50 +14,25 @@ import { Countdown } from "../components/Countdown";
|
|||
import { formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||
import { location, shipModel, tripRealMs } from "../game";
|
||||
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). */
|
||||
export function Fleet() {
|
||||
const state = useLoadedPlayer();
|
||||
const [deadheading, setDeadheading] = useState<Ship | null>(null);
|
||||
|
||||
const flying = state.ships.filter((s) => s.status === "in-flight").length;
|
||||
|
||||
return (
|
||||
<Screen className="max-w-4xl">
|
||||
<ScreenHeader
|
||||
title="Fleet"
|
||||
eyebrow={
|
||||
state.ships.length > 0
|
||||
? `${state.ships.length} ${state.ships.length === 1 ? "ship" : "ships"} · ${flying} flying`
|
||||
: "Your ships"
|
||||
}
|
||||
icon="fleet"
|
||||
/>
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Fleet</h1>
|
||||
{state.ships.length === 0 ? (
|
||||
<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 —{" "}
|
||||
<Link to="/market" className="font-semibold text-amber-300 underline">
|
||||
visit the Market
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Panel>
|
||||
<p className="text-sm text-slate-400">
|
||||
No ships yet —{" "}
|
||||
<Link to="/market" className="text-amber-400 underline">
|
||||
visit the Market
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
<ul className="space-y-2">
|
||||
{state.ships.map((ship) => (
|
||||
<ShipRow
|
||||
key={ship.id}
|
||||
|
|
@ -70,7 +45,7 @@ export function Fleet() {
|
|||
</ul>
|
||||
)}
|
||||
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
||||
</Screen>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -83,79 +58,55 @@ function ShipRow(props: {
|
|||
const { ship, dispatch, pilotName } = props;
|
||||
const now = useClock((s) => s.now);
|
||||
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
|
||||
const model = shipModel(ship.modelId);
|
||||
|
||||
return (
|
||||
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
|
||||
<Art
|
||||
sprite={model.sprite}
|
||||
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
||||
: `At ${locationName(ship.locationId ?? "")}`}
|
||||
{" · "}
|
||||
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
|
||||
{pilotName ?? "No pilot"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<li className="rounded-lg border border-slate-800 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">{ship.name}</span>
|
||||
<span className="text-sm text-slate-400">{shipModelName(ship.modelId)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-300">
|
||||
{dispatch
|
||||
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
||||
: `At ${locationName(ship.locationId ?? "")}`}
|
||||
{" · "}
|
||||
<span className="text-slate-400">{pilotName ?? "No pilot"}</span>
|
||||
</div>
|
||||
{dispatch && (
|
||||
<div>
|
||||
<Meter label={`${ship.name} progress`} value={progress} tone="cyan" />
|
||||
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
|
||||
<span>{progress}%</span>
|
||||
<span>
|
||||
Arrives in{" "}
|
||||
<span className="text-cyan-200">
|
||||
<Countdown to={dispatch.arrivalAt} />
|
||||
</span>
|
||||
</span>
|
||||
<div className="mt-2">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={`${ship.name} progress`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress}
|
||||
className="h-2 overflow-hidden rounded bg-slate-800"
|
||||
>
|
||||
<div className="h-full bg-sky-400" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 text-right text-xs text-slate-400">
|
||||
Arrives in <Countdown to={dispatch.arrivalAt} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ship.status === "idle" && (
|
||||
<div className="mt-auto flex flex-wrap gap-2">
|
||||
{ship.locationId && (
|
||||
<Link
|
||||
to={`/contracts?location=${ship.locationId}`}
|
||||
className={`${buttonClass("primary", "sm")} flex-1 basis-40`}
|
||||
>
|
||||
<Icon name="jobs" className="size-4" />
|
||||
Find work here
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={props.onDeadhead}
|
||||
className="flex-1 basis-40"
|
||||
>
|
||||
<Icon name="send" className="size-4" />
|
||||
Deadhead (fly empty)
|
||||
</Button>
|
||||
</div>
|
||||
{ship.status === "idle" && ship.locationId && (
|
||||
<Link
|
||||
to={`/contracts?location=${ship.locationId}`}
|
||||
className="mt-2 mr-2 inline-block rounded-md border border-slate-700 px-3 py-1 text-sm"
|
||||
>
|
||||
Find work here
|
||||
</Link>
|
||||
)}
|
||||
</Panel>
|
||||
{ship.status === "idle" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onDeadhead}
|
||||
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
|
||||
>
|
||||
Deadhead (fly empty)
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -214,16 +165,21 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
|||
});
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
titleId="deadhead-title"
|
||||
eyebrow="Fly empty"
|
||||
title={`Deadhead ${ship.name}`}
|
||||
subtitle={`Fly empty from ${locationName(origin)} to another place you know. You pay fuel only.`}
|
||||
>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{options.map((option) => {
|
||||
const sprite = content.location(option.id)?.sprite;
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="deadhead-title"
|
||||
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">
|
||||
Deadhead {ship.name}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -240,35 +196,34 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
|||
{ onSuccess: onClose },
|
||||
)
|
||||
}
|
||||
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"
|
||||
className="w-full rounded-lg border border-slate-700 p-3 text-left disabled:opacity-50"
|
||||
>
|
||||
{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="font-display font-bold tracking-wide">
|
||||
{locationName(option.id)}
|
||||
</span>
|
||||
<span className="font-display text-sm text-ink-300 tabular-nums">
|
||||
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
|
||||
</span>
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">{locationName(option.id)}</span>
|
||||
<span className="text-sm tabular-nums text-slate-300">
|
||||
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
|
||||
</span>
|
||||
{option.problem && (
|
||||
<span className="mt-0.5 block text-sm text-rose-300">{option.problem}</span>
|
||||
)}
|
||||
</span>
|
||||
{option.problem && (
|
||||
<span className="mt-1 block text-sm text-red-400">{option.problem}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{reposition.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-rose-300">
|
||||
{errorMessage(reposition.error)}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="secondary" block onClick={onClose} className="mt-4">
|
||||
Cancel
|
||||
</Button>
|
||||
</Sheet>
|
||||
))}
|
||||
</ul>
|
||||
{reposition.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-400">
|
||||
{errorMessage(reposition.error)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-4 w-full rounded-lg border border-slate-700 py-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { TutorialState } from "@deadhead/schema";
|
||||
import { dispatchProgress, realToGame } from "@deadhead/sim";
|
||||
import { Link } from "react-router";
|
||||
import { realToGame } from "@deadhead/sim";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { GoalsCard } from "../components/GoalsCard";
|
||||
import { LoanCard } from "../components/LoanCard";
|
||||
import { RouteStrip } from "../components/RouteStrip";
|
||||
import { formatCredits, locationName } from "../format";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { buttonClass, Eyebrow, Icon, Panel, Screen, ScreenHeader, StatTile } from "../ui";
|
||||
|
||||
/** What the player needs to do next to reach the deal (decision 0.5.30). */
|
||||
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
|
||||
|
|
@ -35,130 +32,72 @@ export function HQ() {
|
|||
const resting = state.pilots
|
||||
.filter((p) => p.resting && p.restEndsAt !== null)
|
||||
.sort((a, b) => a.restEndsAt! - b.restEndsAt!);
|
||||
const nextShip = next?.tutorial
|
||||
? "ship-company-courier"
|
||||
: content.shipModel(state.ships.find((s) => s.id === next?.shipId)?.modelId ?? "")?.sprite;
|
||||
|
||||
return (
|
||||
<Screen className="max-w-5xl">
|
||||
<ScreenHeader title="HQ" eyebrow={state.company.name} icon="hq" />
|
||||
<section className="space-y-4 p-4">
|
||||
<h1 className="text-xl font-semibold">HQ</h1>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatTile
|
||||
label="Credits"
|
||||
value={formatCredits(state.company.credits)}
|
||||
icon="credits"
|
||||
tone="amber"
|
||||
/>
|
||||
<StatTile
|
||||
label="Game date"
|
||||
value={formatGameTime(realToGame(now, content.config))}
|
||||
icon="clock"
|
||||
/>
|
||||
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
|
||||
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Stat label="Credits" value={formatCredits(state.company.credits)} />
|
||||
<Stat label="Game date" value={formatGameTime(realToGame(now, content.config))} />
|
||||
<Stat label="Ships idle" value={String(idle)} />
|
||||
<Stat label="Ships in flight" value={String(flying)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{state.tutorial && (
|
||||
<Panel feature tone="amber">
|
||||
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
|
||||
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
|
||||
Flying for Halvorsen Lunar Freight
|
||||
</h2>
|
||||
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
|
||||
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
||||
{runsBeforeDeal}
|
||||
</p>
|
||||
<RunPips done={state.tutorial.runsCompleted} total={runsBeforeDeal} />
|
||||
<p aria-label="Next step" className="mt-2 text-sm text-ink-300">
|
||||
{tutorialHint(state.tutorial, runsBeforeDeal)}
|
||||
</p>
|
||||
{!state.tutorial.runInFlight && !state.tutorial.dealAvailable && (
|
||||
<Link to="/contracts" className={`mt-3 ${buttonClass("primary", "md", true)}`}>
|
||||
<Icon name="send" className="size-4" />
|
||||
Fly a company run
|
||||
</Link>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel feature tone={next ? "cyan" : "default"}>
|
||||
<h2 className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase">
|
||||
Next arrival
|
||||
</h2>
|
||||
{next ? (
|
||||
<>
|
||||
<div className="mt-2">
|
||||
<RouteStrip
|
||||
originId={next.originId}
|
||||
destinationId={next.destinationId}
|
||||
progress={dispatchProgress(next, now)}
|
||||
shipSprite={nextShip}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm text-ink-200">
|
||||
{locationName(next.originId)} → {locationName(next.destinationId)} in
|
||||
</span>
|
||||
<span className="font-display text-2xl font-bold text-cyan-200">
|
||||
<Countdown to={next.arrivalAt} />
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{!state.tutorial && (
|
||||
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
|
||||
<Icon name="rest" className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
|
||||
Pilots resting: {resting.length}
|
||||
</h2>
|
||||
{resting[0] ? (
|
||||
<p className="text-sm text-sky-200">
|
||||
{resting[0].name} wakes in{" "}
|
||||
<Countdown to={resting[0].restEndsAt!} done="now" />
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!state.tutorial && state.ships.length === 0 && (
|
||||
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!state.tutorial && <GoalsCard />}
|
||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-800 p-3">
|
||||
<h2 className="text-sm font-semibold text-slate-300">Next arrival</h2>
|
||||
{next ? (
|
||||
<p className="mt-1">
|
||||
{locationName(next.originId)} → {locationName(next.destinationId)} in{" "}
|
||||
<Countdown to={next.arrivalAt} />
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-slate-400">Nothing in flight.</p>
|
||||
)}
|
||||
</div>
|
||||
</Screen>
|
||||
|
||||
{!state.tutorial && (
|
||||
<div aria-label="Pilot rest" className="rounded-lg border border-slate-800 p-3">
|
||||
<h2 className="text-sm font-semibold text-slate-300">Pilots resting: {resting.length}</h2>
|
||||
{resting[0] ? (
|
||||
<p className="mt-1">
|
||||
{resting[0].name} wakes in <Countdown to={resting[0].restEndsAt!} done="now" />
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-slate-400">Everyone is ready to fly.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.tutorial && state.ships.length === 0 && (
|
||||
<p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p>
|
||||
)}
|
||||
|
||||
{state.tutorial && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
|
||||
<h2 className="font-semibold text-amber-300">Flying for Halvorsen Lunar Freight</h2>
|
||||
<p aria-label="Company runs" className="mt-1 text-sm text-slate-300">
|
||||
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
||||
{runsBeforeDeal}
|
||||
</p>
|
||||
<p aria-label="Next step" className="mt-1 text-xs text-slate-400">
|
||||
{tutorialHint(state.tutorial, runsBeforeDeal)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.tutorial && <GoalsCard />}
|
||||
|
||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pip per company run toward the deal. */
|
||||
function RunPips({ done, total }: { done: number; total: number }) {
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div aria-hidden="true" className="mt-2 flex gap-1.5">
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-2 flex-1 rounded-full ${i < done ? "bg-amber-400 shadow-glow-amber" : "bg-space-700"}`}
|
||||
/>
|
||||
))}
|
||||
<div className="rounded-lg border border-slate-800 p-3">
|
||||
<div className="text-xs text-slate-400">{label}</div>
|
||||
<div className="mt-1 font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ import { distanceAt } from "../game";
|
|||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { pairOptions } from "./DispatchSheet";
|
||||
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;
|
||||
|
||||
|
|
@ -57,29 +54,23 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
|||
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Location"
|
||||
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div role="group" aria-label="Location" className="flex flex-wrap gap-2">
|
||||
{discovered.map((id) => {
|
||||
const idle = idleAt(id);
|
||||
const sprite = content.location(id)?.sprite;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={id === locationId}
|
||||
onClick={() => setParams({ location: id }, { replace: true })}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
|
||||
{locationName(id)}
|
||||
{idle > 0 && (
|
||||
<span
|
||||
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
|
||||
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"
|
||||
className="rounded-full bg-sky-500/25 px-1.5 text-xs tabular-nums text-sky-200"
|
||||
>
|
||||
{idle}
|
||||
</span>
|
||||
|
|
@ -89,8 +80,7 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
|||
})}
|
||||
</div>
|
||||
{idleAt(locationId) === 0 && (
|
||||
<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" />
|
||||
<p className="text-sm text-slate-400">
|
||||
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -118,97 +108,77 @@ function LocationBoard({
|
|||
},
|
||||
});
|
||||
|
||||
if (board.isPending) return <p className="text-ink-400">Loading contracts…</p>;
|
||||
if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>;
|
||||
if (board.isError) {
|
||||
return (
|
||||
<div role="alert" className="flex items-center gap-3 text-rose-300">
|
||||
<div role="alert" className="text-red-400">
|
||||
Could not load contracts.{" "}
|
||||
<Button variant="danger" size="sm" onClick={() => void board.refetch()}>
|
||||
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const refresh = (
|
||||
<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" />
|
||||
<p aria-label="Board refresh" className="text-sm text-slate-400">
|
||||
New contracts in{" "}
|
||||
<span className="text-cyan-200">
|
||||
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
||||
</span>
|
||||
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
||||
</p>
|
||||
);
|
||||
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
|
||||
if (board.data.contracts.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{refresh}
|
||||
{stuck}
|
||||
<Panel tone="muted" className="text-center text-sm text-ink-400">
|
||||
No contracts left this period.
|
||||
</Panel>
|
||||
<p className="text-sm text-slate-400">No contracts left this period.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{refresh}
|
||||
{stuck}
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
<ul className="space-y-2">
|
||||
{board.data.contracts.map((contract) => {
|
||||
const requirements = requirementsText(contract);
|
||||
return (
|
||||
<Panel
|
||||
as="li"
|
||||
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">
|
||||
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||
{contract.firstContact && (
|
||||
<Pill tone="amber" icon="star" className="ml-2 align-middle">
|
||||
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
||||
First contact
|
||||
</Pill>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
|
||||
<span className="font-semibold tabular-nums text-emerald-400">
|
||||
{formatCredits(contract.payout)}
|
||||
</span>
|
||||
</div>
|
||||
<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)}
|
||||
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
|
||||
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
||||
</span>
|
||||
<div className="mt-1 text-sm text-slate-400">
|
||||
{cargoName(contract.cargoTypeId)}
|
||||
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
|
||||
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
||||
</div>
|
||||
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
|
||||
<span className="text-ink-400">
|
||||
<span className={requirements ? "text-amber-200" : "text-ink-300"}>
|
||||
{requirements ? `Needs ${requirements}` : "No skills needed"}
|
||||
</span>{" "}
|
||||
· expires in <Countdown to={contract.expiresAt} done="expired" />
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-slate-400">
|
||||
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
|
||||
<Countdown to={contract.expiresAt} done="expired" />
|
||||
</span>
|
||||
{onSelect && (
|
||||
<Button size="sm" onClick={() => onSelect(contract)} className="shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(contract)}
|
||||
className="shrink-0 rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
|
@ -248,37 +218,37 @@ function NothingHere({
|
|||
{stuck.map((ship) => {
|
||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id)!;
|
||||
return (
|
||||
<Panel
|
||||
<div
|
||||
key={ship.id}
|
||||
role="group"
|
||||
aria-label={`Nothing for ${ship.name}`}
|
||||
tone="cyan"
|
||||
className="text-sm"
|
||||
className="rounded-lg border border-sky-900 bg-sky-950/40 p-3 text-sm"
|
||||
>
|
||||
<p className="flex items-center gap-2 text-cyan-100">
|
||||
<Icon name="alert" className="size-4 shrink-0 text-cyan-300" />
|
||||
<p>
|
||||
No job here that {ship.name} and {pilot.name} can take.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={rest.isPending || state.tutorial !== null}
|
||||
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
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setDeadheading(ship)}>
|
||||
<Icon name="send" className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeadheading(ship)}
|
||||
className="rounded-md border border-slate-700 px-3 py-1"
|
||||
>
|
||||
Deadhead (fly empty)
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rest.isError && (
|
||||
<p role="alert" className="text-sm text-rose-300">
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{errorMessage(rest.error)}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useClock } from "../clock/clock";
|
|||
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
||||
import { distanceAt } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { Art, Button, buttonClass, Icon, Panel, Pill, Screen, ScreenHeader } from "../ui";
|
||||
|
||||
const TYPE_LABELS: Record<Location["type"], string> = {
|
||||
"atmospheric-world": "Atmospheric world",
|
||||
|
|
@ -25,120 +24,72 @@ export function Locations() {
|
|||
const lease = useGameAction("leaseBay");
|
||||
|
||||
return (
|
||||
<Screen className="max-w-4xl">
|
||||
<ScreenHeader title="Locations" eyebrow="Known space" icon="places" />
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Locations</h1>
|
||||
{lease.isError && (
|
||||
<p role="alert" className="text-sm text-rose-300">
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{errorMessage(lease.error)}
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
<ul className="space-y-2">
|
||||
{content.locations.map((location) => {
|
||||
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
|
||||
if (!discovered) {
|
||||
return (
|
||||
<Panel
|
||||
as="li"
|
||||
key={location.id}
|
||||
tone="muted"
|
||||
className="flex items-center gap-4 text-sm"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Art sprite={location.sprite} className="size-16 opacity-40 grayscale" />
|
||||
<Icon
|
||||
name="lock"
|
||||
className="absolute top-1/2 left-1/2 size-6 -translate-1/2 text-ink-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-display text-lg font-bold tracking-wide text-ink-300">
|
||||
{location.name}
|
||||
</div>
|
||||
<div className="text-ink-400">Undiscovered — deliver here to unlock.</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="font-medium">{location.name}</div>
|
||||
<div className="text-slate-400">Undiscovered — deliver here to unlock.</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const hasBay = state.bays.some((b) => b.locationId === location.id);
|
||||
const affordable = state.company.credits >= location.bayLeasePrice;
|
||||
return (
|
||||
<Panel
|
||||
as="li"
|
||||
key={location.id}
|
||||
tone={hasBay ? "emerald" : "default"}
|
||||
className="flex flex-col text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Art
|
||||
sprite={location.sprite}
|
||||
className="size-20 shrink-0 drop-shadow-[0_0_14px_rgb(125_211_252/0.35)]"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-display text-xl font-bold tracking-wide">
|
||||
{location.name}
|
||||
</span>
|
||||
{hasBay && (
|
||||
<Pill tone="emerald" icon="hq">
|
||||
HQ
|
||||
</Pill>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-display text-xs tracking-wider text-cyan-300/80">
|
||||
{location.id === "earth"
|
||||
? "Home"
|
||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
||||
</span>
|
||||
</div>
|
||||
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">{location.name}</span>
|
||||
<span className="text-slate-400">
|
||||
{location.id === "earth"
|
||||
? "Home"
|
||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg bg-space-950/50 p-2.5 text-ink-200 ring-1 ring-space-700">
|
||||
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||
Type
|
||||
</dt>
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-slate-300">
|
||||
<dt className="text-slate-400">Type</dt>
|
||||
<dd>{TYPE_LABELS[location.type]}</dd>
|
||||
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||
Landing
|
||||
</dt>
|
||||
<dt className="text-slate-400">Landing</dt>
|
||||
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
|
||||
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||
Produces
|
||||
</dt>
|
||||
<dt className="text-slate-400">Produces</dt>
|
||||
<dd>{location.produces.map(cargoName).join(", ") || "—"}</dd>
|
||||
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||
Demands
|
||||
</dt>
|
||||
<dt className="text-slate-400">Demands</dt>
|
||||
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
|
||||
</dl>
|
||||
<Link
|
||||
to={`/contracts?location=${location.id}`}
|
||||
className={`mt-3 ${buttonClass("secondary", "sm")}`}
|
||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||
>
|
||||
<Icon name="jobs" className="size-4" />
|
||||
View contracts
|
||||
</Link>
|
||||
{hasBay ? (
|
||||
<p className="mt-3 flex items-center gap-1.5 font-display font-semibold text-emerald-300">
|
||||
<Icon name="check" className="size-4" />
|
||||
Your HQ is here
|
||||
</p>
|
||||
<p className="mt-2 font-medium text-emerald-400">Your HQ is here</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-3 text-ink-400">{HQ_EXPLANATION}</p>
|
||||
<Button
|
||||
<p className="mt-2 text-slate-400">{HQ_EXPLANATION}</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Lease bay at ${location.name}`}
|
||||
block
|
||||
disabled={!affordable || lease.isPending}
|
||||
onClick={() => lease.mutate({ locationId: location.id })}
|
||||
className="mt-2"
|
||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Lease bay ({formatCredits(location.bayLeasePrice)})
|
||||
</Button>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Screen>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import type { MapSelection } from "../map/hitTest";
|
|||
import { MapCanvas } from "../map/MapCanvas";
|
||||
import { modelsReachingFromEarth } from "../map/reach";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { Art, buttonClass, Icon } from "../ui";
|
||||
|
||||
const TYPE_LABELS: Record<Location["type"], string> = {
|
||||
"atmospheric-world": "Atmospheric world",
|
||||
|
|
@ -27,16 +26,15 @@ export function MapScreen() {
|
|||
const [selection, setSelection] = useState<MapSelection | null>(null);
|
||||
|
||||
return (
|
||||
// Fill the space between the header and the bottom nav (none beside the
|
||||
// desktop rail); the negative margin cancels the layout's scroll padding so
|
||||
// the page never scrolls (CR-005).
|
||||
<section className="relative -mb-[calc(var(--nav-h)+1.5rem)] h-[calc(100dvh-var(--header-h)-var(--nav-h))]">
|
||||
// Fill the space between the header (2.3rem) and the bottom nav (2.6rem + safe
|
||||
// area); -mb-20 cancels the layout's scroll padding so the page never scrolls.
|
||||
<section className="relative -mb-20 h-[calc(100dvh-4.875rem-env(safe-area-inset-bottom))]">
|
||||
<h1 className="sr-only">Map</h1>
|
||||
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
||||
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
|
||||
className="absolute right-3 top-3 rounded-md bg-slate-900/80 px-2 py-1 text-xs text-slate-300"
|
||||
>
|
||||
Reset view
|
||||
</button>
|
||||
|
|
@ -52,12 +50,10 @@ export function MapScreen() {
|
|||
|
||||
function Panel({
|
||||
title,
|
||||
art,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
art?: ReactNode;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
|
|
@ -65,25 +61,15 @@ function Panel({
|
|||
<div
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
className="absolute inset-x-2 bottom-2 animate-sheet rounded-2xl border border-cyan-400/40 bg-space-900/90 p-4 text-sm shadow-panel backdrop-blur-xl md:inset-x-auto md:right-4 md:bottom-4 md:w-96"
|
||||
className="absolute inset-x-2 bottom-2 rounded-xl border border-slate-700 bg-slate-900/95 p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{art}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h2 className="font-display text-lg font-bold tracking-wide">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="-mt-1 -mr-1 grid size-9 shrink-0 place-items-center rounded-lg text-ink-400 hover:bg-white/5 hover:text-ink-50"
|
||||
>
|
||||
<Icon name="close" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">{children}</div>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Close" className="px-2 text-slate-400">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -98,46 +84,31 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
|||
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={location.name}
|
||||
onClose={onClose}
|
||||
art={
|
||||
<Art
|
||||
sprite={location.sprite}
|
||||
className={`size-16 shrink-0 ${discovered ? "" : "opacity-40 grayscale"}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Panel title={location.name} onClose={onClose}>
|
||||
{discovered ? (
|
||||
<>
|
||||
<p className="text-ink-200">
|
||||
<p className="text-slate-300">
|
||||
{TYPE_LABELS[location.type]} ·{" "}
|
||||
{location.id === "earth"
|
||||
? "Home"
|
||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
||||
</p>
|
||||
<p className="text-ink-400">
|
||||
<p className="text-slate-400">
|
||||
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
||||
{location.demands.map(cargoName).join(", ") || "nothing"}
|
||||
</p>
|
||||
<p className={hasBay ? "text-emerald-300" : "text-ink-400"}>
|
||||
{hasBay ? "Your HQ is here." : "No HQ here."}
|
||||
</p>
|
||||
<Link
|
||||
to={`/contracts?location=${location.id}`}
|
||||
className={`mt-2 ${buttonClass("primary", "sm")}`}
|
||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||
>
|
||||
<Icon name="jobs" className="size-4" />
|
||||
View contracts
|
||||
</Link>
|
||||
<p className="mt-1 text-slate-400">{hasBay ? "Your HQ is here." : "No HQ here."}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="flex items-center gap-1.5 text-ink-300">
|
||||
<Icon name="lock" className="size-4 shrink-0" />
|
||||
Undiscovered — deliver here to unlock.
|
||||
</p>
|
||||
<p aria-label="Reachable with" className="text-ink-200">
|
||||
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
|
||||
<p aria-label="Reachable with" className="mt-1 text-slate-300">
|
||||
{reachable.length > 0
|
||||
? `Reachable with: ${reachable.map((m) => m.name).join(", ")}`
|
||||
: "No ship can reach it from Earth right now."}
|
||||
|
|
@ -157,27 +128,16 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: ()
|
|||
? "Company loaner"
|
||||
: (state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "Ship");
|
||||
const pilotName = state.pilots.find((p) => p.id === dispatch.pilotId)?.name ?? "Unknown pilot";
|
||||
const shipSprite = dispatch.tutorial
|
||||
? "ship-company-courier"
|
||||
: (content.shipModel(state.ships.find((s) => s.id === dispatch.shipId)?.modelId ?? "")
|
||||
?.sprite ?? "ship-courier");
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={shipName}
|
||||
onClose={onClose}
|
||||
art={<Art sprite={shipSprite} className="size-14 shrink-0 rotate-45" />}
|
||||
>
|
||||
<p className="font-display text-ink-50">
|
||||
<Panel title={shipName} onClose={onClose}>
|
||||
<p className="text-slate-300">
|
||||
{locationName(dispatch.originId)} → {locationName(dispatch.destinationId)}
|
||||
</p>
|
||||
<p className="text-ink-300">Pilot: {pilotName}</p>
|
||||
<p className="text-ink-300">
|
||||
Arrives in{" "}
|
||||
<span className="font-display text-cyan-200">
|
||||
<Countdown to={dispatch.arrivalAt} />
|
||||
</span>{" "}
|
||||
({formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
||||
<p className="text-slate-400">Pilot: {pilotName}</p>
|
||||
<p className="text-slate-400">
|
||||
Arrives in <Countdown to={dispatch.arrivalAt} /> (
|
||||
{formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
||||
</p>
|
||||
</Panel>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,19 +2,9 @@ import { content, type ShipModel } from "@deadhead/content";
|
|||
import { useState } from "react";
|
||||
import { formatCredits, formatMillionKm, locationName } from "../format";
|
||||
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 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). */
|
||||
export function Market() {
|
||||
const state = useLoadedPlayer();
|
||||
|
|
@ -22,78 +12,50 @@ export function Market() {
|
|||
const [buying, setBuying] = useState<ShipModel | null>(null);
|
||||
|
||||
return (
|
||||
<Screen className="max-w-5xl">
|
||||
<ScreenHeader
|
||||
title="Market"
|
||||
eyebrow="Shipyard"
|
||||
icon="market"
|
||||
subtitle="New ships are delivered with no pilot to one of your HQs."
|
||||
/>
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Market</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
New ships are delivered with no pilot to one of your HQs.
|
||||
</p>
|
||||
{buy.isError && (
|
||||
<p role="alert" className="text-sm text-rose-300">
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{errorMessage(buy.error)}
|
||||
</p>
|
||||
)}
|
||||
{buy.isSuccess && (
|
||||
<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" />
|
||||
<p role="status" className="text-sm text-emerald-400">
|
||||
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid gap-4 md:grid-cols-3">
|
||||
{MODELS.map((model) => {
|
||||
<ul className="space-y-2">
|
||||
{content.purchasableShipModels.map((model) => {
|
||||
const affordable = state.company.credits >= model.price;
|
||||
return (
|
||||
<Panel as="li" key={model.id} feature={affordable} className="flex flex-col text-sm">
|
||||
<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%">
|
||||
<div className="absolute inset-x-8 bottom-4 h-3 rounded-[50%] bg-cyan-300/15 blur-md" />
|
||||
<Art
|
||||
sprite={model.sprite}
|
||||
className="relative size-32 rotate-[30deg] drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
|
||||
/>
|
||||
<li key={model.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<span className="font-semibold tabular-nums">{formatCredits(model.price)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline justify-between gap-2">
|
||||
<span className="font-display text-xl font-bold tracking-wide">{model.name}</span>
|
||||
<span
|
||||
className={`font-display text-base font-bold tabular-nums ${affordable ? "text-amber-300" : "text-ink-400"}`}
|
||||
>
|
||||
{formatCredits(model.price)}
|
||||
</span>
|
||||
</div>
|
||||
<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 className="mt-2 grid grid-cols-2 gap-x-3 gap-y-0.5 text-slate-300">
|
||||
<dt className="text-slate-400">Speed</dt>
|
||||
<dd>{formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h</dd>
|
||||
<dt className="text-slate-400">Capacity</dt>
|
||||
<dd>{model.cargoCapacityTonnes} t</dd>
|
||||
<dt className="text-slate-400">Range</dt>
|
||||
<dd>{formatMillionKm(model.fuelRangeMillionKm)}</dd>
|
||||
<dt className="text-slate-400">Landing</dt>
|
||||
<dd>{LANDING_LABELS[model.landingClass]}</dd>
|
||||
</dl>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Buy ${model.name}`}
|
||||
block
|
||||
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
||||
onClick={() => setBuying(model)}
|
||||
className="mt-4"
|
||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
{affordable ? "Buy" : "Not enough credits"}
|
||||
</Button>
|
||||
</Panel>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
|
@ -107,30 +69,7 @@ export function Market() {
|
|||
onClose={() => setBuying(null)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -145,45 +84,52 @@ function DeliveryPicker(props: {
|
|||
const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
titleId="delivery-title"
|
||||
eyebrow="Delivery"
|
||||
title="Deliver to which HQ?"
|
||||
subtitle={`Your ${props.model.name} will wait there, idle and without a pilot.`}
|
||||
>
|
||||
<fieldset className="mt-4 space-y-2">
|
||||
<legend className="sr-only">HQ</legend>
|
||||
{state.bays.map((bay) => {
|
||||
const sprite = content.location(bay.locationId)?.sprite;
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delivery-title"
|
||||
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">
|
||||
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>
|
||||
{state.bays.map((bay) => (
|
||||
<label
|
||||
key={bay.id}
|
||||
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"
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-700 p-3 text-sm"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="hq"
|
||||
checked={bayId === bay.id}
|
||||
onChange={() => setBayId(bay.id)}
|
||||
className="size-4 accent-amber-400"
|
||||
/>
|
||||
{sprite && <Art sprite={sprite} className="size-8" />}
|
||||
<span className="font-display font-semibold">{locationName(bay.locationId)}</span>
|
||||
{locationName(bay.locationId)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
<Button
|
||||
block
|
||||
disabled={bayId === null || props.pending}
|
||||
onClick={() => bayId && props.onBuy(bayId)}
|
||||
className="mt-4"
|
||||
>
|
||||
Buy for {formatCredits(props.model.price)}
|
||||
</Button>
|
||||
<Button variant="secondary" block onClick={props.onClose} className="mt-2">
|
||||
Cancel
|
||||
</Button>
|
||||
</Sheet>
|
||||
))}
|
||||
</fieldset>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bayId === null || props.pending}
|
||||
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"
|
||||
>
|
||||
Buy for {formatCredits(props.model.price)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
className="mt-2 w-full rounded-lg border border-slate-700 py-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
shipModelName,
|
||||
} from "../format";
|
||||
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. */
|
||||
export function levelProgress(xp: number, level: number) {
|
||||
|
|
@ -26,20 +25,20 @@ export function levelProgress(xp: number, level: number) {
|
|||
export function Pilots() {
|
||||
const state = useLoadedPlayer();
|
||||
return (
|
||||
<Screen className="max-w-4xl">
|
||||
<ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Pilots</h1>
|
||||
<HirePilot />
|
||||
{!state.pilots.some((p) => !p.isAvatar) && (
|
||||
<p className="text-sm text-ink-400">
|
||||
<p className="text-sm text-slate-400">
|
||||
No hired pilots yet. Hire one at a bay to crew another ship.
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
<ul className="space-y-2">
|
||||
{state.pilots.map((pilot) => (
|
||||
<PilotCard key={pilot.id} pilot={pilot} />
|
||||
))}
|
||||
</ul>
|
||||
</Screen>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -51,23 +50,18 @@ function HirePilot() {
|
|||
const hasBay = state.bays.some((b) => b.locationId === bay);
|
||||
|
||||
return (
|
||||
<Panel feature tone="amber" className="text-sm">
|
||||
<div className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
{state.bays.length === 0 ? (
|
||||
<p className="text-ink-300">Lease a bay to hire pilots there.</p>
|
||||
<p className="text-slate-400">Lease a bay to hire pilots there.</p>
|
||||
) : (
|
||||
<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>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="font-display text-xs tracking-wider text-ink-300 uppercase">
|
||||
Hire at
|
||||
</span>
|
||||
<span className="text-slate-400">Hire at</span>
|
||||
<select
|
||||
aria-label="Hire at"
|
||||
value={bay}
|
||||
onChange={(e) => setBay(e.target.value)}
|
||||
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"
|
||||
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
||||
>
|
||||
{state.bays.map((b) => (
|
||||
<option key={b.id} value={b.locationId}>
|
||||
|
|
@ -76,21 +70,22 @@ function HirePilot() {
|
|||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasBay || state.company.credits < cost || hire.isPending}
|
||||
onClick={() => hire.mutate({ locationId: bay })}
|
||||
className="ml-auto"
|
||||
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Hire pilot ({formatCredits(cost)})
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hire.isError && (
|
||||
<p role="alert" className="mt-2 text-rose-300">
|
||||
<p role="alert" className="mt-2 text-red-400">
|
||||
{errorMessage(hire.error)}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -101,60 +96,23 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
|||
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
|
||||
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
|
||||
|
||||
const initials = pilot.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.slice(0, 2);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<li className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{pilot.name}
|
||||
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-display text-lg font-bold tracking-wide">
|
||||
{pilot.name}
|
||||
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
|
||||
</span>
|
||||
<span className="font-display text-xs tracking-wider whitespace-nowrap text-ink-300 uppercase">
|
||||
Level {pilot.level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-ink-400">
|
||||
{progress
|
||||
? `${progress.xp} / ${progress.needed} XP to next level`
|
||||
: `${pilot.xp} XP · max level`}
|
||||
</div>
|
||||
<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-violet-500 to-fuchsia-300"
|
||||
style={{ width: `${progress ? (progress.xp / progress.needed) * 100 : 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-slate-400">Level {pilot.level}</span>
|
||||
</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 ?? "")}`
|
||||
)}
|
||||
<div className="mt-1 text-slate-400">
|
||||
{progress
|
||||
? `${progress.xp} / ${progress.needed} XP to next level`
|
||||
: `${pilot.xp} XP · max level`}
|
||||
</div>
|
||||
<div className="mt-1 text-slate-300">
|
||||
{pilot.status === "in-flight" ? "In flight" : `At ${locationName(pilot.locationId ?? "")}`}
|
||||
{" · "}
|
||||
{lockedToLoaner
|
||||
? "Flying the company loaner"
|
||||
|
|
@ -163,31 +121,19 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
|||
: "No ship"}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
|
||||
<div
|
||||
className={`font-display text-[11px] tracking-[0.14em] uppercase ${pilot.skillPointsAvailable > 0 ? "text-amber-300" : "text-ink-400"}`}
|
||||
>
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-slate-400">
|
||||
Skill points:{" "}
|
||||
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
|
||||
</div>
|
||||
<ul className="mt-2 grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
<ul className="mt-1 grid grid-cols-2 gap-1">
|
||||
{content.skills.map((skill) => {
|
||||
const level = pilot.skills[skill.id] ?? 0;
|
||||
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
|
||||
return (
|
||||
<li key={skill.id} className="flex items-center justify-between gap-1">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs text-ink-200">
|
||||
{skill.name} {level}/{skill.maxLevel}
|
||||
</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>
|
||||
{skill.name} {level}/{skill.maxLevel}
|
||||
</span>
|
||||
{canRaise && (
|
||||
<button
|
||||
|
|
@ -195,9 +141,9 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
|||
aria-label={`Raise ${skill.name} for ${pilot.name}`}
|
||||
disabled={spend.isPending}
|
||||
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
|
||||
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"
|
||||
className="rounded border border-amber-500 px-1.5 text-amber-300"
|
||||
>
|
||||
<Icon name="plus" className="size-4" />
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
|
|
@ -205,7 +151,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
|||
})}
|
||||
</ul>
|
||||
{spend.isError && (
|
||||
<p role="alert" className="mt-1 text-rose-300">
|
||||
<p role="alert" className="mt-1 text-red-400">
|
||||
{errorMessage(spend.error)}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -216,7 +162,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
|||
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
|
||||
<AssignShip pilot={pilot} />
|
||||
)}
|
||||
</Panel>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -233,68 +179,75 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
|
|||
|
||||
if (pilot.resting && pilot.restEndsAt !== null) {
|
||||
return (
|
||||
<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" />
|
||||
</span>
|
||||
<div className="mt-2 text-sky-300" aria-label={`${pilot.name} rest`}>
|
||||
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||
<div className="mt-2">
|
||||
<div className="flex items-baseline justify-between text-xs text-slate-400">
|
||||
<span>Duty</span>
|
||||
<span className="tracking-normal normal-case">
|
||||
<span>
|
||||
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter
|
||||
label={`${pilot.name} duty`}
|
||||
value={used}
|
||||
max={pilot.dutyLimit}
|
||||
tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
/>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={`${pilot.name} duty`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={pilot.dutyLimit}
|
||||
aria-valuenow={used}
|
||||
className="mt-1 h-1.5 overflow-hidden rounded bg-slate-800"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-amber-400"
|
||||
style={{ width: `${(used / pilot.dutyLimit) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{canRest && pilot.status === "idle" && !confirming && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setConfirming(true)} className="mt-3">
|
||||
<Icon name="rest" className="size-4" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="mt-2 rounded-md border border-slate-700 px-3 py-1"
|
||||
>
|
||||
Rest now
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
{confirming && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`Rest ${pilot.name}?`}
|
||||
className="mt-3 rounded-lg border border-sky-400/30 bg-sky-950/40 p-3"
|
||||
className="mt-2 rounded-md bg-slate-900 p-2"
|
||||
>
|
||||
<p className="text-ink-200">
|
||||
<p className="text-slate-300">
|
||||
{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.
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
<button
|
||||
type="button"
|
||||
disabled={rest.isPending}
|
||||
onClick={() =>
|
||||
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}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setConfirming(false)}>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="rounded-md border border-slate-700 px-3 py-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{rest.isError && (
|
||||
<p role="alert" className="mt-1 text-rose-300">
|
||||
<p role="alert" className="mt-1 text-red-400">
|
||||
{errorMessage(rest.error)}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -313,18 +266,18 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
|
|||
|
||||
if (ships.length === 0) {
|
||||
return (
|
||||
<p className="mt-3 text-xs text-ink-400">
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
No uncrewed ships at {locationName(pilot.locationId ?? "")}.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
aria-label={`Assign ${pilot.name} to`}
|
||||
value={shipId}
|
||||
onChange={(e) => setShipId(e.target.value)}
|
||||
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"
|
||||
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
||||
>
|
||||
<option value="">Choose a ship…</option>
|
||||
{ships.map((s) => (
|
||||
|
|
@ -333,15 +286,16 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
<button
|
||||
type="button"
|
||||
disabled={!shipId || assign.isPending}
|
||||
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
|
||||
className="rounded-md border border-slate-700 px-3 py-1 disabled:opacity-40"
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</button>
|
||||
{assign.isError && (
|
||||
<p role="alert" className="text-rose-300">
|
||||
<p role="alert" className="text-red-400">
|
||||
{errorMessage(assign.error)}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { useNavigate } from "react-router";
|
||||
import { useApi } from "../api/context";
|
||||
import { PLAYER_STATE_KEY } from "../state/player";
|
||||
import { Art, Button, Icon } from "../ui";
|
||||
|
||||
/** First launch: create a guest account (decision: guests first, accounts later). */
|
||||
export function StartScreen() {
|
||||
|
|
@ -20,46 +19,25 @@ export function StartScreen() {
|
|||
});
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh flex-col items-center justify-center gap-8 overflow-hidden p-6 text-center text-ink-50">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<Art
|
||||
sprite="sun"
|
||||
className="absolute -top-24 -right-24 size-72 opacity-80 blur-[1px] sm:size-96"
|
||||
/>
|
||||
<Art sprite="body-earth" className="absolute bottom-[14%] -left-10 size-40 sm:size-56" />
|
||||
<Art
|
||||
sprite="body-moon"
|
||||
className="absolute bottom-[34%] left-[30%] size-10 sm:left-[18%]"
|
||||
/>
|
||||
<Art
|
||||
sprite="ship-courier"
|
||||
className="absolute top-[22%] left-[12%] size-16 rotate-[60deg] drop-shadow-[0_0_10px_rgb(251_191_36/0.5)]"
|
||||
/>
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-slate-950 p-6 text-center text-slate-100">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-wide">DEADHEAD</h1>
|
||||
<p className="mt-1 text-slate-400">Space Freight Tycoon</p>
|
||||
</div>
|
||||
|
||||
<div className="relative animate-rise">
|
||||
<h1 className="bg-linear-to-b from-amber-100 via-amber-300 to-amber-600 bg-clip-text font-display text-5xl font-bold tracking-[0.2em] text-transparent drop-shadow-[0_0_24px_rgb(251_191_36/0.35)] sm:text-7xl">
|
||||
DEADHEAD
|
||||
</h1>
|
||||
<p className="mt-2 font-display text-sm tracking-[0.45em] text-cyan-300/80 uppercase">
|
||||
Space Freight Tycoon
|
||||
</p>
|
||||
</div>
|
||||
<p className="relative max-w-sm animate-rise text-base text-ink-200">
|
||||
<p className="max-w-xs text-sm text-slate-300">
|
||||
Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
|
||||
empire across the solar system.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => start.mutate()}
|
||||
disabled={start.isPending}
|
||||
className="relative animate-rise px-10"
|
||||
className="rounded-lg bg-amber-500 px-6 py-3 font-semibold text-slate-950 disabled:opacity-60"
|
||||
>
|
||||
<Icon name="send" className="size-5" />
|
||||
{start.isPending ? "Starting…" : "Start your company"}
|
||||
</Button>
|
||||
</button>
|
||||
{start.isError && (
|
||||
<p role="alert" className="relative text-sm text-rose-300">
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
Could not start a company. Check your connection and try again.
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import { useClock } from "../clock/clock";
|
|||
import { formatCredits, formatDuration, locationName, cargoName } from "../format";
|
||||
import { distanceAt, tripRealMs } from "../game";
|
||||
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;
|
||||
|
||||
|
|
@ -25,49 +23,46 @@ export function TutorialBoard() {
|
|||
const loaner = loanerModel(content);
|
||||
const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0;
|
||||
|
||||
if (board.isPending) return <p className="p-4 text-ink-400">Loading contracts…</p>;
|
||||
if (board.isPending) return <p className="p-4 text-slate-400">Loading contracts…</p>;
|
||||
if (board.isError) {
|
||||
return (
|
||||
<div role="alert" className="flex items-center gap-3 p-4 text-rose-300">
|
||||
<div role="alert" className="p-4 text-red-400">
|
||||
Could not load contracts.{" "}
|
||||
<Button variant="danger" size="sm" onClick={() => void board.refetch()}>
|
||||
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{firstRun && (
|
||||
<Panel aria-label="Intro" feature tone="amber" className="flex items-center gap-3 text-sm">
|
||||
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
|
||||
<div>
|
||||
<p className="font-display text-base font-bold text-amber-200">
|
||||
Halvorsen Lunar Freight needs a pilot.
|
||||
</p>
|
||||
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
|
||||
</div>
|
||||
</Panel>
|
||||
<div
|
||||
aria-label="Intro"
|
||||
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
||||
>
|
||||
<p className="font-semibold text-amber-200">Halvorsen Lunar Freight needs a pilot.</p>
|
||||
<p className="text-slate-300">Their ship, your hands. Get the crates to the Moon.</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-ink-300">
|
||||
<p className="text-sm text-slate-400">
|
||||
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
||||
</p>
|
||||
{runInFlight && (
|
||||
<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" />
|
||||
<p className="rounded-lg bg-slate-800 p-2 text-sm">
|
||||
Your run is in flight. Wait for it to land.
|
||||
</p>
|
||||
)}
|
||||
{fly.isError && (
|
||||
<p role="alert" className="text-sm text-rose-300">
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{errorMessage(fly.error)}
|
||||
</p>
|
||||
)}
|
||||
{board.data.contracts.length === 0 && (
|
||||
<p className="text-sm text-ink-400">No company runs left this period. Check back soon.</p>
|
||||
<p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p>
|
||||
)}
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
<ul className="space-y-2">
|
||||
{board.data.contracts.map((contract, index) => {
|
||||
// On the first run, suggest the lightest (fastest) contract.
|
||||
const suggested = firstRun && index === 0;
|
||||
|
|
@ -86,90 +81,74 @@ export function TutorialBoard() {
|
|||
contract.tier,
|
||||
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 (
|
||||
<Panel
|
||||
as="li"
|
||||
<li
|
||||
key={contract.id}
|
||||
data-suggested={suggested || undefined}
|
||||
data-scripted={scripted ? true : undefined}
|
||||
tone={scripted ? "fuchsia" : suggested ? "amber" : "default"}
|
||||
feature={Boolean(scripted) || suggested}
|
||||
className={`flex flex-col gap-2 ${suggested ? "shadow-glow-amber" : ""}`}
|
||||
className={`rounded-lg border p-3 ${border}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-display font-bold tracking-wide">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{scripted ? (
|
||||
<span className="text-fuchsia-200">{scripted.name}</span>
|
||||
<span className="text-fuchsia-300">{scripted.name}</span>
|
||||
) : (
|
||||
<>
|
||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||
</>
|
||||
)}
|
||||
{suggested && (
|
||||
<Pill tone="amber" icon="star" className="ml-2 align-middle">
|
||||
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
||||
Suggested
|
||||
</Pill>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm whitespace-nowrap text-ink-300">
|
||||
<Icon name="cargo" className="size-4 text-ink-400" />
|
||||
<span className="text-sm text-slate-400">
|
||||
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
|
||||
</span>
|
||||
</div>
|
||||
{scripted && (
|
||||
<p className="text-xs text-fuchsia-200">
|
||||
<p className="mt-1 text-xs text-fuchsia-200">
|
||||
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
|
||||
double pay, no questions asked
|
||||
</p>
|
||||
)}
|
||||
<RouteStrip
|
||||
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 className="mt-2 flex items-center justify-between gap-2">
|
||||
<dl className="grid flex-1 grid-cols-3 text-sm">
|
||||
<div>
|
||||
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
|
||||
Pay
|
||||
</dt>
|
||||
<dd
|
||||
aria-label="Pay"
|
||||
className="font-display text-base font-bold text-emerald-300 tabular-nums"
|
||||
>
|
||||
<dt className="text-xs text-slate-400">Pay</dt>
|
||||
<dd aria-label="Pay" className="font-semibold tabular-nums text-emerald-400">
|
||||
{formatCredits(contract.payout)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
|
||||
Time
|
||||
</dt>
|
||||
<dd aria-label="Trip time" className="font-display text-base tabular-nums">
|
||||
<dt className="text-xs text-slate-400">Time</dt>
|
||||
<dd aria-label="Trip time" className="tabular-nums">
|
||||
{formatDuration(tripMs)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
|
||||
XP
|
||||
</dt>
|
||||
<dd
|
||||
aria-label="XP"
|
||||
className="font-display text-base text-violet-300 tabular-nums"
|
||||
>
|
||||
<dt className="text-xs text-slate-400">XP</dt>
|
||||
<dd aria-label="XP" className="tabular-nums">
|
||||
+{xp}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Button
|
||||
size="sm"
|
||||
<button
|
||||
type="button"
|
||||
disabled={runInFlight || fly.isPending}
|
||||
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
||||
className="shrink-0"
|
||||
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Fly it
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-linear-to-b from-amber-300 to-amber-500 text-space-950 shadow-glow-amber hover:from-amber-200 hover:to-amber-400",
|
||||
secondary:
|
||||
"border border-space-500 bg-space-800/80 text-ink-50 hover:border-amber-400/60 hover:bg-space-700",
|
||||
ghost: "text-ink-300 hover:bg-white/5 hover:text-ink-50",
|
||||
success:
|
||||
"bg-linear-to-b from-emerald-300 to-emerald-500 text-space-950 shadow-glow-emerald hover:from-emerald-200 hover:to-emerald-400",
|
||||
danger: "border border-rose-500/60 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: "min-h-9 px-3 text-xs",
|
||||
md: "min-h-11 px-4 text-sm",
|
||||
lg: "min-h-13 px-6 text-base",
|
||||
};
|
||||
|
||||
/** Classes for anything that should look like a button, e.g. a router `Link`. */
|
||||
export function buttonClass(
|
||||
variant: ButtonVariant = "primary",
|
||||
size: ButtonSize = "md",
|
||||
block = false,
|
||||
): string {
|
||||
return [
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg font-display font-semibold uppercase tracking-wider",
|
||||
"transition duration-150 select-none active:translate-y-px active:scale-[0.98]",
|
||||
"disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none disabled:saturate-50",
|
||||
VARIANTS[variant],
|
||||
SIZES[size],
|
||||
block ? "w-full" : "",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** Full width. */
|
||||
block?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant,
|
||||
size,
|
||||
block,
|
||||
className = "",
|
||||
type = "button",
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button type={type} className={`${buttonClass(variant, size, block)} ${className}`} {...rest} />
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import type { ElementType, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type Tone = "default" | "amber" | "cyan" | "emerald" | "rose" | "fuchsia" | "muted";
|
||||
|
||||
const TONES: Record<Tone, string> = {
|
||||
default: "border-space-600/70 bg-space-900/75",
|
||||
muted: "border-space-700/70 bg-space-900/45",
|
||||
amber: "border-amber-400/45 bg-amber-950/40",
|
||||
cyan: "border-cyan-400/40 bg-cyan-950/40",
|
||||
emerald: "border-emerald-400/40 bg-emerald-950/40",
|
||||
rose: "border-rose-500/45 bg-rose-950/40",
|
||||
fuchsia: "border-fuchsia-400/50 bg-fuchsia-950/40",
|
||||
};
|
||||
|
||||
const BRACKETS: Record<Tone, string> = {
|
||||
default: "border-cyan-300/60",
|
||||
muted: "border-ink-500",
|
||||
amber: "border-amber-300",
|
||||
cyan: "border-cyan-300",
|
||||
emerald: "border-emerald-300",
|
||||
rose: "border-rose-300",
|
||||
fuchsia: "border-fuchsia-300",
|
||||
};
|
||||
|
||||
export interface PanelProps extends HTMLAttributes<HTMLElement> {
|
||||
as?: ElementType;
|
||||
tone?: Tone;
|
||||
/** Corner brackets, for panels that should stand out. */
|
||||
feature?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/** A translucent HUD panel (decision 0.5.47). */
|
||||
export function Panel({
|
||||
as: Tag = "div",
|
||||
tone = "default",
|
||||
feature = false,
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: PanelProps) {
|
||||
return (
|
||||
<Tag
|
||||
className={`relative rounded-xl border p-4 shadow-panel backdrop-blur-md ${TONES[tone]} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{feature && <Brackets tone={tone} />}
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function Brackets({ tone }: { tone: Tone }) {
|
||||
const c = `pointer-events-none absolute size-3 ${BRACKETS[tone]}`;
|
||||
return (
|
||||
<span aria-hidden="true">
|
||||
<span className={`${c} -top-px -left-px rounded-tl-xl border-t-2 border-l-2`} />
|
||||
<span className={`${c} -top-px -right-px rounded-tr-xl border-t-2 border-r-2`} />
|
||||
<span className={`${c} -bottom-px -left-px rounded-bl-xl border-b-2 border-l-2`} />
|
||||
<span className={`${c} -right-px -bottom-px rounded-br-xl border-r-2 border-b-2`} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small uppercase label above a value or section. */
|
||||
export function Eyebrow({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`font-display text-[11px] font-semibold tracking-[0.18em] text-ink-400 uppercase ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
/** A scrolling screen's content column: centred, padded, and rising in on open. */
|
||||
export function Screen({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={`mx-auto w-full max-w-3xl animate-rise space-y-4 p-4 md:p-6 ${className}`}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Tone } from "./Panel";
|
||||
|
||||
const TITLE_TONES: Partial<Record<Tone, string>> = {
|
||||
amber: "text-amber-300",
|
||||
emerald: "text-emerald-300",
|
||||
cyan: "text-cyan-200",
|
||||
};
|
||||
const BORDER_TONES: Partial<Record<Tone, string>> = {
|
||||
amber: "border-amber-400/50",
|
||||
emerald: "border-emerald-400/40",
|
||||
cyan: "border-cyan-400/40",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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`. Portalled
|
||||
* to `body`: a transformed ancestor (the screens' rise-in) would otherwise
|
||||
* become the containing block of `position: fixed`.
|
||||
*/
|
||||
export function Sheet({
|
||||
titleId,
|
||||
title,
|
||||
eyebrow,
|
||||
subtitle,
|
||||
tone = "default",
|
||||
layer = "z-20",
|
||||
children,
|
||||
}: {
|
||||
titleId: string;
|
||||
title: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
tone?: Tone;
|
||||
/** Stacking layer, e.g. `z-30` for a sheet over other sheets. */
|
||||
layer?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={`relative max-h-[88dvh] w-full max-w-md animate-sheet overflow-y-auto rounded-t-2xl border bg-space-900/95 p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl ${BORDER_TONES[tone] ?? "border-space-600"}`}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto -mt-2 mb-3 h-1 w-10 rounded-full bg-space-600 sm:hidden"
|
||||
/>
|
||||
{eyebrow && (
|
||||
<div className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase">
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
id={titleId}
|
||||
className={`font-display text-xl font-bold tracking-wide ${TITLE_TONES[tone] ?? "text-ink-50"}`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && <div className="mt-1 text-sm text-ink-300">{subtitle}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { ART_BASE_URL } from "../map/art/load";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import type { Tone } from "./Panel";
|
||||
|
||||
const PILL_TONES: Record<Tone, string> = {
|
||||
default: "border-space-500 bg-space-800 text-ink-200",
|
||||
muted: "border-space-600 bg-space-900 text-ink-400",
|
||||
amber: "border-amber-400/50 bg-amber-500/15 text-amber-200",
|
||||
cyan: "border-cyan-400/50 bg-cyan-500/15 text-cyan-200",
|
||||
emerald: "border-emerald-400/50 bg-emerald-500/15 text-emerald-200",
|
||||
rose: "border-rose-400/50 bg-rose-500/15 text-rose-200",
|
||||
fuchsia: "border-fuchsia-400/50 bg-fuchsia-500/15 text-fuchsia-200",
|
||||
};
|
||||
|
||||
/** A small status or tag chip. */
|
||||
export function Pill({
|
||||
tone = "default",
|
||||
icon,
|
||||
className = "",
|
||||
children,
|
||||
}: {
|
||||
tone?: Tone;
|
||||
icon?: IconName;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-display text-[11px] font-semibold tracking-wider whitespace-nowrap uppercase ${PILL_TONES[tone]} ${className}`}
|
||||
>
|
||||
{icon && <Icon name={icon} className="size-3" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const METER_FILLS: Partial<Record<Tone, string>> = {
|
||||
amber: "from-amber-500 to-amber-300 shadow-[0_0_10px] shadow-amber-400/60",
|
||||
cyan: "from-cyan-500 to-sky-300 shadow-[0_0_10px] shadow-cyan-400/60",
|
||||
emerald: "from-emerald-500 to-emerald-300 shadow-[0_0_10px] shadow-emerald-400/60",
|
||||
rose: "from-rose-600 to-rose-400 shadow-[0_0_10px] shadow-rose-500/60",
|
||||
fuchsia: "from-fuchsia-500 to-fuchsia-300",
|
||||
};
|
||||
|
||||
/** A labelled `progressbar`. `label` is its accessible name. */
|
||||
export function Meter({
|
||||
label,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
tone = "cyan",
|
||||
size = "md",
|
||||
className = "",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
tone?: Tone;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}) {
|
||||
const percent = max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
className={`overflow-hidden rounded-full bg-space-950/80 ring-1 ring-space-600/60 ${size === "sm" ? "h-1.5" : "h-2.5"} ${className}`}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full bg-linear-to-r transition-[width] duration-700 ${METER_FILLS[tone] ?? METER_FILLS.cyan}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STAT_VALUE_TONES: Partial<Record<Tone, string>> = {
|
||||
amber: "text-amber-300",
|
||||
cyan: "text-cyan-200",
|
||||
emerald: "text-emerald-300",
|
||||
rose: "text-rose-300",
|
||||
};
|
||||
|
||||
/** A label over a big number, with an optional icon. */
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
tone = "default",
|
||||
valueLabel,
|
||||
className = "",
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
icon?: IconName;
|
||||
tone?: Tone;
|
||||
/** Accessible name of the value, when tests or screen readers need it. */
|
||||
valueLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-space-600/70 bg-space-900/70 px-3 py-2.5 shadow-panel backdrop-blur-md ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-display text-[11px] font-semibold tracking-[0.16em] text-ink-400 uppercase">
|
||||
{icon && <Icon name={icon} className="size-3.5" />}
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
aria-label={valueLabel}
|
||||
className={`mt-1 font-display text-lg font-bold tabular-nums ${STAT_VALUE_TONES[tone] ?? "text-ink-50"}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The page title block. `title` is the screen's level-1 heading. */
|
||||
export function ScreenHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
subtitle,
|
||||
icon,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
subtitle?: ReactNode;
|
||||
icon?: IconName;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{icon && (
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-amber-400/40 bg-amber-500/10 text-amber-300 shadow-glow-amber">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{eyebrow && (
|
||||
<div className="font-display text-[11px] font-semibold tracking-[0.22em] text-cyan-300/80 uppercase">
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="font-display text-2xl leading-tight font-bold tracking-wide uppercase">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-sm text-ink-300">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A generated map sprite's URL (decision 0.5.50). */
|
||||
export function spriteUrl(sprite: string, density: "1x" | "2x" = "2x"): string {
|
||||
return `${ART_BASE_URL}/${sprite}@${density}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ship or location art. Decorative: the name is always in text beside it, so
|
||||
* `alt` is empty. A missing image hides itself (decision 0.5.50).
|
||||
*/
|
||||
export function Art({ sprite, className = "size-12" }: { sprite: string; className?: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) return null;
|
||||
return (
|
||||
<img
|
||||
src={spriteUrl(sprite, "1x")}
|
||||
srcSet={`${spriteUrl(sprite, "1x")} 1x, ${spriteUrl(sprite, "2x")} 2x`}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className={`object-contain select-none ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
/** Line icons on a 24 px grid, drawn in `currentColor` (CR-005). */
|
||||
const PATHS = {
|
||||
hq: (
|
||||
<>
|
||||
<path d="M3 21h18M5 21V9l7-5 7 5v12" />
|
||||
<path d="M9.5 21v-5h5v5M9 11.5h.01M15 11.5h.01" />
|
||||
</>
|
||||
),
|
||||
map: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<ellipse cx="12" cy="12" rx="10" ry="4.5" transform="rotate(-25 12 12)" />
|
||||
<circle cx="20" cy="7.5" r="1" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
jobs: (
|
||||
<>
|
||||
<rect x="5" y="4" width="14" height="17" rx="2" />
|
||||
<path d="M9 4V2.5h6V4M8.5 10h7M8.5 14h7M8.5 18h4" />
|
||||
</>
|
||||
),
|
||||
fleet: (
|
||||
<>
|
||||
<path d="M12 2.5c3 2 5 6 5 10l-1.6 3.5H8.6L7 12.5c0-4 2-8 5-10z" />
|
||||
<circle cx="12" cy="10" r="1.8" />
|
||||
<path d="M8.6 16 6 18.5V14l1.3-2.5M15.4 16l2.6 2.5V14l-1.3-2.5M10.5 19l1.5 2.5 1.5-2.5" />
|
||||
</>
|
||||
),
|
||||
pilots: (
|
||||
<>
|
||||
<path d="M6 11a6 6 0 0 1 12 0v2H6v-2z" />
|
||||
<path d="M8 13v1.5a4 4 0 0 0 8 0V13M4 21c1.2-2.6 4.2-4 8-4s6.8 1.4 8 4" />
|
||||
</>
|
||||
),
|
||||
market: (
|
||||
<>
|
||||
<path d="M5 8h14l-1.2 13H6.2L5 8z" />
|
||||
<path d="M9 10V6.5a3 3 0 0 1 6 0V10" />
|
||||
</>
|
||||
),
|
||||
places: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<path d="M5.2 15.6c-2.3 2-3 3.6-2.2 4.3 1.3 1.1 6.4-1.1 11-5s7.6-8.2 6.4-9.4c-.7-.7-2.4-.3-4.6 1" />
|
||||
</>
|
||||
),
|
||||
credits: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M14.8 9.4A3 3 0 0 0 12 7.8c-2 0-3.3 1.6-3.3 4.2s1.3 4.2 3.3 4.2a3 3 0 0 0 2.8-1.6" />
|
||||
</>
|
||||
),
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</>
|
||||
),
|
||||
fuel: <path d="M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11z" />,
|
||||
cargo: (
|
||||
<>
|
||||
<path d="M3 7.5 12 3l9 4.5v9L12 21l-9-4.5v-9z" />
|
||||
<path d="M3 7.5 12 12l9-4.5M12 12v9" />
|
||||
</>
|
||||
),
|
||||
arrow: <path d="M4 12h15M14 6l6 6-6 6" />,
|
||||
lock: (
|
||||
<>
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<path d="M8 11V7.5a4 4 0 0 1 8 0V11" />
|
||||
</>
|
||||
),
|
||||
check: <path d="M5 12.5 9.5 17 19 7" />,
|
||||
bolt: <path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z" />,
|
||||
rest: <path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z" />,
|
||||
alert: (
|
||||
<>
|
||||
<path d="M12 3 22 21H2L12 3z" />
|
||||
<path d="M12 10v5M12 18v.01" />
|
||||
</>
|
||||
),
|
||||
close: <path d="M6 6l12 12M18 6 6 18" />,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
target: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
send: <path d="M3 11 21 3l-8 18-2-8-8-2z" />,
|
||||
hire: (
|
||||
<>
|
||||
<circle cx="10" cy="8" r="4" />
|
||||
<path d="M2.5 21c1-3.8 3.9-6 7.5-6 1.8 0 3.4.5 4.7 1.4M19 14v6M16 17h6" />
|
||||
</>
|
||||
),
|
||||
route: (
|
||||
<>
|
||||
<circle cx="5" cy="18" r="2.5" />
|
||||
<circle cx="19" cy="6" r="2.5" />
|
||||
<path d="M7 16.5C12 14 9 9 17 7.5" strokeDasharray="2 2.5" />
|
||||
</>
|
||||
),
|
||||
speed: <path d="M3 17a9 9 0 0 1 18 0M12 17l4-6M7 13.5h.01M12 10h.01M17 13.5h.01" />,
|
||||
range: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" strokeDasharray="2.5 2.5" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</>
|
||||
),
|
||||
landing: <path d="M12 3v11M7.5 9.5 12 14l4.5-4.5M4 20h16M6 17h12" />,
|
||||
star: <path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9L12 3z" />,
|
||||
} satisfies Record<string, ReactNode>;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
export function Icon({ name, className = "size-5" }: { name: IconName; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/** The client's UI kit (CR-005, decision 0.5.48). */
|
||||
export { Button, buttonClass, type ButtonProps } from "./Button";
|
||||
export { Eyebrow, Panel, type Tone } from "./Panel";
|
||||
export { Screen } from "./Screen";
|
||||
export { Sheet } from "./Sheet";
|
||||
export { Art, Meter, Pill, ScreenHeader, spriteUrl, StatTile } from "./bits";
|
||||
export { Icon, type IconName } from "./icons";
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import type { PlayerState, Ship } from "@deadhead/schema";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tabAttention } from "../src/layout/attention";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const ship = (overrides: Partial<Ship>): Ship => ({
|
||||
id: "s",
|
||||
modelId: "courier",
|
||||
name: "Ship",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const base = samplePlayerState();
|
||||
const avatar = base.pilots[0]!;
|
||||
|
||||
const owner = (overrides: Partial<PlayerState>) =>
|
||||
samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }], ...overrides });
|
||||
|
||||
describe("navigation attention badges (Step 10E.3)", () => {
|
||||
it("counts ready goals, ships ready for a job and pilots with skill points", () => {
|
||||
const state = owner({
|
||||
goals: [
|
||||
{ id: "a", status: "ready" },
|
||||
{ id: "b", status: "incomplete" },
|
||||
{ id: "c", status: "ready" },
|
||||
],
|
||||
ships: [
|
||||
ship({ id: "s1", pilotId: "avatar" }), // ready
|
||||
ship({ id: "s2", pilotId: "p2" }), // pilot resting
|
||||
ship({ id: "s3", pilotId: null }), // uncrewed
|
||||
ship({ id: "s4", pilotId: "p3", status: "in-flight", locationId: null }),
|
||||
],
|
||||
pilots: [
|
||||
{ ...avatar, assignedShipId: "s1", skillPointsAvailable: 2 },
|
||||
{
|
||||
...avatar,
|
||||
id: "p2",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s2",
|
||||
resting: true,
|
||||
restEndsAt: 1,
|
||||
},
|
||||
{ ...avatar, id: "p3", isAvatar: false, assignedShipId: "s4", skillPointsAvailable: 1 },
|
||||
],
|
||||
} as Partial<PlayerState>);
|
||||
expect(tabAttention(state)).toEqual({ "/": 2, "/contracts": 1, "/pilots": 2 });
|
||||
});
|
||||
|
||||
it("nudges toward Jobs during the tutorial until a run is in flight", () => {
|
||||
expect(tabAttention(base)["/contracts"]).toBe(1);
|
||||
const flying = samplePlayerState({ tutorial: { ...base.tutorial!, runInFlight: true } });
|
||||
expect(tabAttention(flying)["/contracts"]).toBe(0);
|
||||
});
|
||||
|
||||
it("shows a badge without changing the tab's accessible name", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => base }));
|
||||
const nav = await screen.findByRole("navigation", { name: "Main" });
|
||||
const jobs = within(nav).getByRole("link", { name: "Jobs" });
|
||||
expect(within(jobs).getByTestId("badge-Jobs")).toHaveTextContent("1");
|
||||
expect(within(nav).queryByTestId("badge-Pilots")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,12 +5,11 @@ import { screen, waitFor, within } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { formatClock } from "../src/format";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const period = boardPeriodMs(content.config);
|
||||
// A quarter of the way into a board period (derived from config, whatever its length).
|
||||
const NOW = Math.ceil(Date.parse("2100-01-01T00:00:00Z") / period) * period + period / 4;
|
||||
// 17.5 minutes into a board period.
|
||||
const NOW = Math.ceil(Date.parse("2100-01-01T00:00:00Z") / period) * period + 17.5 * 60_000;
|
||||
const periodEndsAt = (boardPeriodIndex(NOW, content.config) + 1) * period;
|
||||
|
||||
const courier: Ship = {
|
||||
|
|
@ -80,7 +79,7 @@ describe("board refresh and the nothing-here case (Step 10D.8)", () => {
|
|||
it("counts down to the next board period", async () => {
|
||||
renderApp(api([heavyJob]), "/contracts?location=earth");
|
||||
expect(await screen.findByLabelText("Board refresh")).toHaveTextContent(
|
||||
`New contracts in ${formatClock(periodEndsAt - NOW)}`,
|
||||
"New contracts in 42:30",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../src/index.css"), "utf8");
|
||||
|
||||
describe("theme (CR-005)", () => {
|
||||
it("self-hosts the display and body fonts", () => {
|
||||
expect(css).toMatch(/@import "@fontsource\/chakra-petch\//);
|
||||
expect(css).toMatch(/@import "@fontsource\/barlow\//);
|
||||
expect(css).toMatch(/--font-display: "Chakra Petch"/);
|
||||
expect(css).toMatch(/--font-sans: "Barlow"/);
|
||||
});
|
||||
|
||||
it("puts every screen on the space backdrop", () => {
|
||||
expect(css).toContain('url("/assets/map-gen/bg.webp")');
|
||||
});
|
||||
|
||||
it("turns motion off under prefers-reduced-motion", () => {
|
||||
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Art, Button, Meter, ScreenHeader, Sheet, spriteUrl } from "../src/ui";
|
||||
|
||||
describe("UI kit (Step 10E.2)", () => {
|
||||
it("buttons default to type=button and forward disabled and clicks", async () => {
|
||||
const onClick = vi.fn();
|
||||
const { rerender } = render(<Button onClick={onClick}>Go</Button>);
|
||||
const button = screen.getByRole("button", { name: "Go" });
|
||||
expect(button).toHaveAttribute("type", "button");
|
||||
await userEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
|
||||
rerender(
|
||||
<Button onClick={onClick} disabled>
|
||||
Go
|
||||
</Button>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Go" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("meters expose their range and value", () => {
|
||||
render(<Meter label="Duty" value={9} max={14} />);
|
||||
const bar = screen.getByRole("progressbar", { name: "Duty" });
|
||||
expect(bar).toHaveAttribute("aria-valuemin", "0");
|
||||
expect(bar).toHaveAttribute("aria-valuemax", "14");
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "9");
|
||||
});
|
||||
|
||||
it("sheets are modal dialogs labelled by their title", () => {
|
||||
render(
|
||||
<Sheet titleId="t" title="Deliver to which HQ?">
|
||||
<p>Body</p>
|
||||
</Sheet>,
|
||||
);
|
||||
const dialog = screen.getByRole("dialog", { name: "Deliver to which HQ?" });
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
expect(screen.getByRole("heading", { level: 2, name: "Deliver to which HQ?" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("the screen header is the level-1 heading", () => {
|
||||
render(<ScreenHeader title="Fleet" eyebrow="Your ships" />);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("art loads the generated sprite and hides itself if it fails", () => {
|
||||
const { container } = render(<Art sprite="ship-courier" />);
|
||||
const img = container.querySelector("img")!;
|
||||
expect(img).toHaveAttribute("src", spriteUrl("ship-courier", "1x"));
|
||||
expect(img).toHaveAttribute("alt", "");
|
||||
expect(img.getAttribute("srcset")).toContain("/assets/map-gen/ship-courier@2x.png 2x");
|
||||
fireEvent.error(img);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 391 KiB |
|
|
@ -1,92 +0,0 @@
|
|||
# Change Request CR-005 — UI Overhaul
|
||||
|
||||
**Project:** Deadhead: Space Freight Tycoon
|
||||
**Applies to:** a build with Phases 1–10, CR-001, CR-002, CR-003 and CR-004 complete
|
||||
**Do this:** as **Phase 10E**, after Phase 10D and before Phase 11
|
||||
**Related docs:** `implementation-plan.md`, `CR-003-map-art.md`, `DECISIONS.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Why
|
||||
|
||||
Every screen except the map is plain slate cards with small text, and navigation is a row of seven small text links at the bottom of the screen. It works, but it doesn't look or feel like a game. The map art from CR-003 is the only place the game's look shows up.
|
||||
|
||||
This change gives the whole client one visual identity, a "freight terminal HUD": deep-space backgrounds, glowing amber and cyan accents, a techy display font for headings and numbers, big icon navigation, and the existing planet and ship art used on the screens, not just the map.
|
||||
|
||||
It is **presentation only**. No game rules, API calls, copy or accessible names change, so the existing client tests are the regression suite for this phase.
|
||||
|
||||
## 2. Visual direction
|
||||
|
||||
| Element | Direction |
|
||||
|---|---|
|
||||
| Background | The map's `bg.webp` behind every screen, dimmed under a navy gradient, so all screens are "in space". |
|
||||
| Surfaces | Translucent navy panels with a thin cool border, a soft inner highlight and corner brackets on feature panels. |
|
||||
| Accents | Amber = primary actions and the player's selection. Cyan = in flight and info. Emerald = money in and success. Rose = errors and blocks. Fuchsia = scripted story jobs. |
|
||||
| Type | **Chakra Petch** (display: headings, numbers, tab labels, buttons, uppercase eyebrow labels) and **Barlow** (body). Self-hosted with `@fontsource`, so it works offline (Phase 11 PWA). |
|
||||
| Numbers | Credits, payouts and countdowns use the display font with tabular figures; key numbers are large. |
|
||||
| Navigation | Icon and label tabs. Phone: a bottom bar with 24 px icons and a glowing active indicator. Desktop (≥ 768 px): a left rail. Small badges call attention to things waiting for the player. |
|
||||
| Art | The map's sprites (`public/assets/map-gen`): ship art on Fleet, Market and the dispatch sheet; planet art on Locations, the contract cards' route, the map panels and HQ. |
|
||||
| Motion | Screens fade and rise in; buttons press down; claimable goals and ready badges pulse gently. All motion is off under `prefers-reduced-motion`. |
|
||||
|
||||
## 3. Rules for this change
|
||||
|
||||
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, and `progress.md` is updated after every step.
|
||||
- **No behavior changes.** Visible copy, roles and accessible names stay as they are, except where a step says otherwise. Existing tests must keep passing without edits to their assertions (a test may change only to follow a renamed or moved element that a step names).
|
||||
- **Styling goes through the UI kit and theme tokens** (Step 10E.1–10E.2). Screens should not hard-code the old `slate-*` palette.
|
||||
- No new art. Screens reuse the generated map sprites; the map's style config (`map/style.ts`) stays the map's own.
|
||||
- Visual quality is a manual check: steps marked "manual check" end with desktop and phone screenshots in `docs/screenshots/`, named in `progress.md`. Stop for the user's review at the end of Phase 10E.
|
||||
|
||||
## 4. Decisions
|
||||
|
||||
Record these in `DECISIONS.md` (as plan §0.5.47–0.5.51).
|
||||
|
||||
- **0.5.47 Visual identity.** One "freight terminal HUD" look for the whole client (§2): space background, translucent panels, amber/cyan/emerald/rose/fuchsia accent roles, Chakra Petch + Barlow, self-hosted.
|
||||
- **0.5.48 Tokens and kit.** Colors, fonts, radii, shadows and motion are Tailwind theme tokens in `apps/web/src/index.css`. Shared components (buttons, panels, sheets, stat tiles, meters, pills, screen headers, art) live in `apps/web/src/ui/`. Screens compose these.
|
||||
- **0.5.49 Navigation.** Icon tabs, bottom bar on phones and a left rail from 768 px wide. Tab order and labels are unchanged. Attention badges are decorative (`aria-hidden`), so each tab's accessible name stays its label; the same information is on the screens.
|
||||
- **0.5.50 Art on screens.** Ships and locations show their content `sprite` from the generated map art, as plain `<img>` with empty `alt` (the name is always in text next to it). A missing image hides itself, matching decision 0.5.41.
|
||||
- **0.5.51 Presentation only.** The overhaul changes no game rules, API calls, copy or accessible names (§3).
|
||||
|
||||
## 5. Steps — Phase 10E
|
||||
|
||||
### Step 10E.1 — Fonts, theme tokens and backdrop
|
||||
- Add `@fontsource/chakra-petch` and `@fontsource/barlow`. Define theme tokens in `index.css` (`@theme`): fonts, the panel/ink color scale, accent colors, glow shadows and keyframes (rise-in, pulse). Body uses Barlow; a global backdrop shows `bg.webp` under a dark gradient.
|
||||
- Respect `prefers-reduced-motion` globally.
|
||||
- **Test:** a test reads `index.css` and checks the font families, the backdrop image and the reduced-motion rule are present.
|
||||
|
||||
### Step 10E.2 — UI kit
|
||||
- `src/ui/`: `Button` (primary, secondary, ghost, success, danger; sizes; pressed feedback), `Panel` (plain, feature with corner brackets, tone), `Sheet` (modal shell: backdrop, bottom sheet on phones, centered card from `sm`), `StatTile`, `Meter` (labelled `progressbar`), `Pill`, `ScreenHeader` (eyebrow, title as the screen's `h1`, subtitle), `Art` (sprite image), and the icon set used by navigation and screens.
|
||||
- **Test:** `ui-kit.test.tsx`: buttons keep `type="button"` and forward `disabled`; `Meter` exposes progressbar values; `Sheet` is an `aria-modal` dialog labelled by its title; `ScreenHeader` renders a level-1 heading; `Art` resolves sprite URLs and hides on error.
|
||||
|
||||
### Step 10E.3 — App shell
|
||||
- HUD header: logo mark and wordmark, credits in a glowing chip, game clock chip. The `Credits` and `Game clock` labels stay.
|
||||
- Navigation per decision 0.5.49: bottom icon bar on phones, left rail from `md`. Badges: HQ = goals ready to claim; Jobs = idle crewed ships not resting; Pilots = pilots with unspent skill points. Computed by a pure `tabAttention(state)`.
|
||||
- The map screen fills exactly the space left by the new header and navigation on both layouts.
|
||||
- **Test:** existing `layout.test.tsx` passes; new tests for `tabAttention` and for a badge appearing without changing the tab's accessible name.
|
||||
|
||||
### Step 10E.4 — HQ dashboard
|
||||
- Company hero (name, credits large), icon stat tiles, a "Next arrival" panel with a route strip and live progress, pilot rest, the tutorial card as a story panel, goals with reward pills, the loan with a meter.
|
||||
- **Test:** existing HQ, goals, loan and rest tests pass. **Manual check.**
|
||||
|
||||
### Step 10E.5 — Contracts and dispatch
|
||||
- Location switcher as planet chips. Contract cards: a route strip (origin art → line → destination art), cargo and mass, a large payout, requirement pills, expiry, and a prominent Dispatch button. The tutorial board gets the same card, with suggested and scripted variants. The dispatch sheet uses the `Sheet` and shows each ship with its art and the numbers as stat tiles.
|
||||
- **Test:** existing contracts, tutorial board, dispatch and board refresh tests pass. **Manual check.**
|
||||
|
||||
### Step 10E.6 — Fleet and Market
|
||||
- Ship cards with ship art, a status pill (idle, in flight), the route and a progress meter in flight, and actions as buttons. The deadhead picker uses `Sheet`. Market cards are "showroom" cards: large art, price, and stat meters for speed, capacity and range relative to the best model, plus landing class.
|
||||
- **Test:** existing fleet and market tests pass. **Manual check.**
|
||||
|
||||
### Step 10E.7 — Pilots
|
||||
- Pilot cards with an initials badge, level and XP meter, skills as pips out of their maximum with a raise button, the duty meter (amber, rose when near the limit) or rest status, and actions as buttons. The hire panel becomes a feature panel.
|
||||
- **Test:** existing pilots and duty tests pass. **Manual check.**
|
||||
|
||||
### Step 10E.8 — Locations, map panels, start screen and dialogs
|
||||
- Location cards with planet art, details as a compact spec list, the HQ status pill and the lease button. Undiscovered locations show dimmed art and a lock. The map's location and flight panels get the panel style with art. The start screen becomes a title screen over the space backdrop, with the sun and planets. The away summary and the deal use `Sheet`.
|
||||
- **Test:** existing locations, map interaction, onboarding, away summary and deal tests pass. **Manual check.**
|
||||
|
||||
### Step 10E.9 — Regression and review
|
||||
- Full suite, coverage, lint, typecheck and web build green. Screenshots of every screen at desktop and phone width (tutorial and mid-game). **Phase 10E complete — stop for review.**
|
||||
|
||||
## 6. Amendments to earlier steps
|
||||
|
||||
- **Step 8.1 (layout):** the header and bottom navigation are restyled per decision 0.5.49; desktop uses a left rail.
|
||||
- **Step 11.1 (PWA):** the self-hosted font files are part of the offline asset cache.
|
||||
|
|
@ -102,11 +102,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
44. **Finish the job** (CR-004). Dispatch is blocked only while a pilot is resting. A trip that will exceed the limit is allowed; mandatory rest starts on arrival.
|
||||
45. **Rest** (CR-004). Starting rest sets duty hours to zero and sets rest-ends-at to start time plus the rest duration. Mandatory rest starts at the trip's arrival time, so it is correct even if resolution happens later (offline).
|
||||
46. **Rest covers a board refresh** (CR-004). The rest duration converted to real time must be at least the contract board period. This is enforced by a content test.
|
||||
47. **Visual identity** (CR-005). One "freight terminal HUD" look for the whole client: space background, translucent panels, amber/cyan/emerald/rose/fuchsia accent roles, Chakra Petch + Barlow fonts, self-hosted.
|
||||
48. **Tokens and kit** (CR-005). Colors, fonts, radii, shadows and motion are Tailwind theme tokens in `apps/web/src/index.css`; shared components live in `apps/web/src/ui/` and screens compose them.
|
||||
49. **Navigation** (CR-005). Icon tabs: a bottom bar on phones, a left rail from 768 px wide. Tab order and labels are unchanged. Attention badges are decorative (`aria-hidden`), so each tab's accessible name stays its label.
|
||||
50. **Art on screens** (CR-005). Ships and locations show their content `sprite` from the generated map art as plain images with empty `alt`; the name is always in text beside it. A missing image hides itself.
|
||||
51. **Presentation only** (CR-005). The overhaul changes no game rules, API calls, copy or accessible names.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -408,7 +403,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
### Step 8.1 — Vite + React app
|
||||
- Create the React app in `apps/web` with Tailwind, a basic layout (header with credits and game clock, main area, bottom navigation for mobile), and routing for: HQ, Contracts, Fleet, Pilots, Market, Locations.
|
||||
- **Test:** A component test renders the layout and each route shows its placeholder title. Manual check: the layout fits a 375px-wide viewport with no horizontal scroll.
|
||||
- **Amended by CR-005:** The header becomes a HUD bar and navigation becomes icon tabs: a bottom bar on phones, a left rail from 768 px, with attention badges (decision 0.5.49, Step 10E.3).
|
||||
|
||||
### Step 8.2 — Typed API client
|
||||
- Build an API client from the route catalog so every call is typed and responses are validated.
|
||||
|
|
@ -526,19 +520,12 @@ Steps 10D.1–10D.10 are specified in `memory-bank/change-requests/CR-004-pilot-
|
|||
|
||||
---
|
||||
|
||||
## Phase 10E — UI Overhaul (CR-005)
|
||||
|
||||
Steps 10E.1–10E.9 are specified in `memory-bank/change-requests/CR-005-ui-overhaul.md` §5, with its §6 amendments to earlier steps. Done after Phase 10D and before Phase 11; stop for review at the end.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — PWA
|
||||
|
||||
### Step 11.1 — Installable PWA
|
||||
- Add `vite-plugin-pwa` with a manifest (name "Deadhead: Space Freight Tycoon", icons, theme color, standalone display) and a service worker that caches the app shell.
|
||||
- **Test:** A Lighthouse PWA audit in CI reports the app as installable. Manual check: the app installs to a phone home screen and launches standalone.
|
||||
- **Amended by CR-003:** The service worker precaches the generated map assets in `public/assets/map-gen/` (not the source art in `public/assets/map/`, which the build omits).
|
||||
- **Amended by CR-005:** The self-hosted font files (`@fontsource` Chakra Petch and Barlow, bundled into `dist/assets`) are part of the offline cache.
|
||||
|
||||
### Step 11.2 — Offline shell behavior
|
||||
- When offline, the app loads from cache and shows the last known state with a clear "Offline — showing last update" banner. In-flight progress bars keep advancing from cached dispatch records. Actions are disabled until back online.
|
||||
|
|
|
|||
|
|
@ -174,17 +174,3 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
|
|||
- [x] **Step 10D.8 — Client: board refresh and the "nothing here" case.** Every location board shows "New contracts in mm:ss" to `periodEndsAt` and refetches itself just after it. For each idle, crewed, non-resting ship at the location with no contract it can take (by the sim, via `pairOptions`), a prompt offers "Rest [pilot] — wakes to a new board" (`restPilot`) and "Deadhead (fly empty)" (opens the Fleet's deadhead picker, now exported). Test: `board-refresh.test.tsx`.
|
||||
- [x] **Step 10D.9 — Client: rest in summaries and on HQ.** The "While you were away" entry for a trip that ended in mandatory rest says "[Pilot] is resting until [local time]". HQ (after the tutorial) has a "Pilot rest" card: "Pilots resting: N" and the soonest to wake with a countdown, or "Everyone is ready to fly." Test: `rest-summary-hq.test.tsx`.
|
||||
- [x] **Step 10D.10 — Plan amendments and regression.** CR-004 §6 amendments added to plan steps 2.1, 3.12, 3.17, 5.3, 9.5, 9.7, 12.1 and 12.6 (§0.5.42–46 were added with the CR). Full suite, coverage, lint and web build green. Scripted manual check against a live server: guest → 4 runs → deal → the avatar chains Earth↔Moon trips (2.1–2.5 game h each) and rests on arrival after the sixth (12 h limit); dispatch while resting is refused with `pilot-resting`; on waking, Earth's board is a new set. **Balance note for Phase 12:** each board has one guaranteed Courier-sized job per period, so after the first trip from a location in a period the Courier often has to deadhead or rest. **Phase 10D complete — stopped for review.**
|
||||
|
||||
## Phase 10E — UI Overhaul (CR-005)
|
||||
|
||||
Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorded as plan §0.5.47–0.5.51.
|
||||
|
||||
- [x] **Step 10E.1 — Fonts, theme tokens and backdrop.** `@fontsource/chakra-petch` (display) and `@fontsource/barlow` (body), self-hosted. `index.css` `@theme`: `font-display`/`font-sans`, `space-*` surfaces, `ink-*` text, glow and panel shadows, `rise`/`glow`/`sheet` animations. `body::before` puts the map's `bg.webp` under a navy gradient behind every screen; global `:focus-visible` ring; all motion off under `prefers-reduced-motion`. Test: `theme.test.ts`.
|
||||
- [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.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.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
||||
- [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
||||
- [x] **Step 10E.8 — Locations, map panels, start screen and dialogs.** Locations ("Known space"): planet-art cards (two columns from `md`), emerald tone and an HQ pill where you have a bay, distance in cyan, specs on an inset `dl`, View contracts as a button, Lease bay as a full-width primary; undiscovered places dimmed and greyed with a padlock. Map: Reset view and the tap panels restyled (glass panel with the location's or ship's art, icon close button, View contracts as a button; a side card from `md`). Start screen is a title screen: gradient DEADHEAD wordmark, the Sun, Earth, Moon and a Courier around it, and a large Start button. "While you were away" and the deal are on `Sheet` ("Logbook" with big emerald credits and violet XP, discoveries with a star, goals panel with a success button; the deal as an amber "Incoming transmission" with the Courier's art and a large "Shake on it"). The last `slate-*` classes are gone from `apps/web/src`. Existing locations, map, onboarding, away and deal tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
||||
- [x] **Step 10E.9 — Regression and review.** CR-005 §6 amendments added to plan steps 8.1 (the CR said 8.2; the layout is 8.1, corrected) and 11.1. Typecheck, lint, full suite with coverage (100 files, 680 tests) and the web build are green. The main JS chunk grew from 496 KB to 523 KB, past Vite's 500 KB warning; splitting it is left for Phase 11/13. **Review screenshots** (palette-quantized PNGs, 6 MB total): `docs/screenshots/10e9-final-{hq,map,contracts,dispatch,fleet,pilots,market,locations}-midgame-{desktop,phone}.png`, `10e9-final-{hq,contracts,map}-tutorial-{desktop,phone}.png` and `10e9-final-start-new-{desktop,phone}.png`. Phone shots are full-page captures, so the fixed bottom navigation appears partway down the image. **Manual check for the user:** the look on a real phone, and tap targets in the 7-tab bar on the narrowest phones. **Phase 10E complete — stopped for review.**
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ export const gameConfig: GameConfig = {
|
|||
loanRepaymentShare: 0.3,
|
||||
// Courier price + Earth bay lease.
|
||||
dealLoanAmount: 5000,
|
||||
contractBoardPeriodRealMinutes: 10,
|
||||
contractBoardPeriodRealMinutes: 60,
|
||||
// Duty and rest (CR-004): about three Moon round trips, then rest.
|
||||
dutyLimitGameHours: 12,
|
||||
longHaulDutyBonusGameHours: 2,
|
||||
// One board period (10 real minutes at 60× compression), so a rested pilot
|
||||
// One board period (60 real minutes at 60× compression), so a rested pilot
|
||||
// always wakes to a fresh board (decision 0.5.46).
|
||||
restGameHours: 10,
|
||||
restGameHours: 60,
|
||||
firstContactChance: 0.1,
|
||||
firstContactPayoutMultiplier: 2,
|
||||
// One band per ship size, so every board has work for every ship (playtest fix):
|
||||
|
|
|
|||
|
|
@ -90,12 +90,6 @@ importers:
|
|||
'@deadhead/sim':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/sim
|
||||
'@fontsource/barlow':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@fontsource/chakra-petch':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.104.1
|
||||
version: 5.104.1(react@19.3.0)
|
||||
|
|
@ -867,12 +861,6 @@ packages:
|
|||
'@fastify/proxy-addr@5.1.1':
|
||||
resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==}
|
||||
|
||||
'@fontsource/barlow@5.3.0':
|
||||
resolution: {integrity: sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==}
|
||||
|
||||
'@fontsource/chakra-petch@5.3.0':
|
||||
resolution: {integrity: sha512-QZvbM/q2R6n1pg2x+TJPftZmBNS+ADU37L4Z4L/6UJIz+JZ/SRaEf8usPFRiP7Ht28sIideMu/Bm1SnGlSrwNw==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
|
@ -3250,10 +3238,6 @@ snapshots:
|
|||
'@fastify/forwarded': 3.0.2
|
||||
ipaddr.js: 2.5.0
|
||||
|
||||
'@fontsource/barlow@5.3.0': {}
|
||||
|
||||
'@fontsource/chakra-petch@5.3.0': {}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
|
|
|
|||