Compare commits
No commits in common. "9564c5bd24a966360464c929d73d5cd3aaae2468" and "81b6afc31f63ee8fcfe1ed17a2bdec9f322fce12" have entirely different histories.
9564c5bd24
...
81b6afc31f
25
DECISIONS.md
|
|
@ -2086,28 +2086,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The countdown is the later of the pilot's rest and the ship's shop time**: rest overlapping service runs inside the shop time, so the shop's end is usually the later one.
|
- **The countdown is the later of the pilot's rest and the ship's shop time**: rest overlapping service runs inside the shop time, so the shop's end is usually the later one.
|
||||||
- **The countdown uses the player's countdown setting** (real, game or both, CR-011), with the same mm:ss as the card's shop line.
|
- **The countdown uses the player's countdown setting** (real, game or both, CR-011), with the same mm:ss as the card's shop line.
|
||||||
- **Unchanged:** an uncrewed ship's Deadhead button (the deadhead sheet already explains "No pilot assigned" per destination), and Deadhead hidden while in the shop.
|
- **Unchanged:** an uncrewed ship's Deadhead button (the deadhead sheet already explains "No pilot assigned" per destination), and Deadhead hidden while in the shop.
|
||||||
|
|
||||||
## CR-029 — Deep Field UI Rework (Phase 29)
|
|
||||||
|
|
||||||
- **Chosen from four prototypes:** the user picked the Deep Field direction with Switchyard's jobs table. Design reference: the Deep Field canvas linked from `memory-bank/change-requests/CR-029-deep-field-ui.md`. Decisions are plan §0.5.165 onward, recorded as each is built.
|
|
||||||
|
|
||||||
## Step 29.1
|
|
||||||
|
|
||||||
- **`--font-display` is Instrument Sans, and Michroma is a new `--font-title`.** `font-display` sits on labels and numbers throughout the screens, so pointing it at Michroma would make small text unreadable. `font-title` goes on the wordmark and screen titles in Step 29.2.
|
|
||||||
- **The default accent palettes (`amber-*`, `cyan-*` and so on) are not retuned.** Screens move to the new role tokens in Step 29.2, where each use can be judged; until then they keep their old colors.
|
|
||||||
- **`--color-ink-100` is new.** `text-ink-100` was used without a token, so that text silently inherited its color.
|
|
||||||
- **The faintest text got much lighter.** `ink-400` and `ink-500` were raised until they read at 4.5:1 on the glass over white, which narrows the gap between text levels. Hierarchy has to come from size and weight as well.
|
|
||||||
- **The theme color and icon background follow the ground** (`--color-space-950`, #050919), and the icons were regenerated with `pnpm icons`.
|
|
||||||
- **Screens outside the map are plain navy until Step 29.3,** when the map moves behind every screen.
|
|
||||||
|
|
||||||
## Steps 29.2–29.9
|
|
||||||
|
|
||||||
- **Accent classes stayed; their scales changed (differs from Step 29.2).** `amber-*`, `cyan-*`, `sky-*`, `emerald-*` and `rose-*` are retuned to the role hues in `index.css`, and screens were swept so amber marks actions and warnings. Rewriting all 350 uses to role tokens by hand added risk and no visible change. New code uses the role tokens.
|
|
||||||
- **Old URLs are kept, not redirected (differs from Step 29.4).** `/pilots`, `/hqs`, `/market`, `/locations` and `/map` remain real routes under their tabs, so links, tests and saved habits all keep working. `/map` is the map view, since a selection needs somewhere to show.
|
|
||||||
- **Fleet has no separate ship detail screen.** The canvas drew one; the cards were made compact instead, and Fuel and Upgrades stay as sheets. It keeps every existing flow and test.
|
|
||||||
- **`font-display` remains in the code as an alias of the body font.** Removing it from about 40 files would change nothing on screen.
|
|
||||||
- **Phones get a nearly opaque sheet and no blur.** Map labels showed through at 82%, and blur is the biggest drawing cost; the 82% glass and blur apply from 768 px.
|
|
||||||
- **The map isn't highlighted for a selected job.** The canvas showed the route; the renderer has no such layer yet, so it was left out of this rework.
|
|
||||||
- **Plain Standard cargo has no chip in the table,** and only real requirements are mentioned; "No skills needed" went away as noise. Payout, distance and expiry stay in the row on wide panels and in the dispatch sheet's job details on phones (payout) or via the board's refresh countdown (expiry).
|
|
||||||
- **Dispatch is a modal below 1280 px** and beside the table above it; there isn't room beside a wide panel below that.
|
|
||||||
- **Step 29.9 checks:** axe (WCAG 2.0, 2.1 and 2.2 A/AA) reports nothing on 17 screens and sheets, though it can't judge text over the map; a 22-ship fleet stays usable. Frame rate held 60 in headless desktop Chrome only: a real mid-range phone is still untested.
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ test.describe("end to end (Phase 12)", () => {
|
||||||
// The Lighter's pair can take it in the dispatch sheet.
|
// The Lighter's pair can take it in the dispatch sheet.
|
||||||
await page.goto("/contracts?location=moon");
|
await page.goto("/contracts?location=moon");
|
||||||
const card = page
|
const card = page
|
||||||
.getByRole("row")
|
.getByRole("listitem")
|
||||||
.filter({ hasText: content.good(job!.goodId)!.name })
|
.filter({ hasText: content.good(job!.goodId)!.name })
|
||||||
.filter({ hasText: `${job!.massTonnes} t` });
|
.filter({ hasText: `${job!.massTonnes} t` });
|
||||||
await card.getByRole("button", { name: "Dispatch" }).first().click();
|
await card.getByRole("button", { name: "Dispatch" }).first().click();
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ test.describe("installable PWA (Step 11.1)", () => {
|
||||||
expect(manifest).toMatchObject({
|
expect(manifest).toMatchObject({
|
||||||
name: "Deadhead: Space Freight Tycoon",
|
name: "Deadhead: Space Freight Tycoon",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
theme_color: "#050919",
|
theme_color: "#080d1d",
|
||||||
});
|
});
|
||||||
const sizes = manifest.icons.map((i: { sizes: string; purpose?: string }) => i.sizes);
|
const sizes = manifest.icons.map((i: { sizes: string; purpose?: string }) => i.sizes);
|
||||||
expect(sizes).toEqual(expect.arrayContaining(["192x192", "512x512"]));
|
expect(sizes).toEqual(expect.arrayContaining(["192x192", "512x512"]));
|
||||||
|
|
@ -43,8 +43,8 @@ test.describe("installable PWA (Step 11.1)", () => {
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.stringContaining("/index.html"),
|
expect.stringContaining("/index.html"),
|
||||||
expect.stringMatching(/\/assets\/map-gen\/body-earth@2x\.png/),
|
expect.stringMatching(/\/assets\/map-gen\/body-earth@2x\.png/),
|
||||||
expect.stringMatching(/\/assets\/.*michroma.*\.woff2/),
|
expect.stringMatching(/\/assets\/.*chakra-petch.*\.woff2/),
|
||||||
expect.stringMatching(/\/assets\/.*instrument-sans.*\.woff2/),
|
expect.stringMatching(/\/assets\/.*barlow.*\.woff2/),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const urls = await page.evaluate(async () => {
|
const urls = await page.evaluate(async () => {
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ export async function playToDeal(page: Page) {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await dismissLogbook(page);
|
await dismissLogbook(page);
|
||||||
await page.getByRole("button", { name: "Shake on it" }).click();
|
await page.getByRole("button", { name: "Shake on it" }).click();
|
||||||
await expect(page.getByRole("heading", { level: 1, name: "Bridge" })).toBeVisible();
|
await expect(page.getByRole("heading", { level: 1, name: "Dashboard" })).toBeVisible();
|
||||||
// Both pilots have skill points at the deal (CR-017); the pop-up says so.
|
// Both pilots have skill points at the deal (CR-017); the pop-up says so.
|
||||||
const skills = page.getByRole("dialog", { name: "Your pilots are ready to train" });
|
const skills = page.getByRole("dialog", { name: "Your pilots are ready to train" });
|
||||||
await expect(skills.getByLabel("Unspent skill points")).toContainText(/You: \d+ points?/);
|
await expect(skills.getByLabel("Unspent skill points")).toContainText(/You: \d+ points?/);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#050919" />
|
<meta name="theme-color" content="#080d1d" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
<title>Deadhead: Space Freight Tycoon</title>
|
<title>Deadhead: Space Freight Tycoon</title>
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@
|
||||||
"@deadhead/engine": "workspace:*",
|
"@deadhead/engine": "workspace:*",
|
||||||
"@deadhead/schema": "workspace:*",
|
"@deadhead/schema": "workspace:*",
|
||||||
"@deadhead/sim": "workspace:*",
|
"@deadhead/sim": "workspace:*",
|
||||||
"@fontsource/instrument-sans": "^5.3.0",
|
"@fontsource/barlow": "^5.3.0",
|
||||||
"@fontsource/michroma": "^5.3.0",
|
"@fontsource/chakra-petch": "^5.3.0",
|
||||||
"@tanstack/react-query": "^5.104.1",
|
"@tanstack/react-query": "^5.104.1",
|
||||||
"pixi.js": "^8.22.0",
|
"pixi.js": "^8.22.0",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
|
@ -9,8 +9,8 @@ import sharp from "sharp";
|
||||||
|
|
||||||
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const outDir = join(webRoot, "public/icons");
|
const outDir = join(webRoot, "public/icons");
|
||||||
/** --color-space-950 in src/index.css: the theme and background color. */
|
/** --color-space-900 in src/index.css: the theme and background color. */
|
||||||
export const BACKGROUND = "#050919";
|
export const BACKGROUND = "#080d1d";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each icon: size in px and how much of it the ship fills. Maskable icons keep
|
* Each icon: size in px and how much of it the ship fills. Maskable icons keep
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, type ReactNode } from "react";
|
import { lazy, Suspense, useEffect, type ReactNode } from "react";
|
||||||
import { Route, Routes } from "react-router";
|
import { Route, Routes } from "react-router";
|
||||||
import { useApi } from "./api/context";
|
import { useApi } from "./api/context";
|
||||||
import { startClockTicker, syncClock } from "./clock/clock";
|
import { startClockTicker, syncClock } from "./clock/clock";
|
||||||
|
|
@ -17,9 +17,7 @@ import { Fleet } from "./screens/Fleet";
|
||||||
import { Dashboard } from "./screens/Dashboard";
|
import { Dashboard } from "./screens/Dashboard";
|
||||||
import { HQs } from "./screens/HQs";
|
import { HQs } from "./screens/HQs";
|
||||||
import { Locations } from "./screens/Locations";
|
import { Locations } from "./screens/Locations";
|
||||||
import { MapScreen } from "./screens/MapScreen";
|
|
||||||
import { Market } from "./screens/Market";
|
import { Market } from "./screens/Market";
|
||||||
import { More } from "./screens/More";
|
|
||||||
import { Pilots } from "./screens/Pilots";
|
import { Pilots } from "./screens/Pilots";
|
||||||
import { Logbook } from "./screens/Logbook";
|
import { Logbook } from "./screens/Logbook";
|
||||||
import { Settings } from "./screens/Settings";
|
import { Settings } from "./screens/Settings";
|
||||||
|
|
@ -29,11 +27,22 @@ import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||||
import { RadioCallSheet, useRefetchOnRadio } from "./components/RadioCalls";
|
import { RadioCallSheet, useRefetchOnRadio } from "./components/RadioCalls";
|
||||||
import { Button } from "./ui";
|
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 })));
|
||||||
|
|
||||||
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
||||||
"/": <Dashboard />,
|
"/": <Dashboard />,
|
||||||
|
"/map": (
|
||||||
|
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
|
||||||
|
<MapScreen />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
"/contracts": <Contracts />,
|
"/contracts": <Contracts />,
|
||||||
"/fleet": <Fleet />,
|
"/fleet": <Fleet />,
|
||||||
"/more": <More />,
|
"/pilots": <Pilots />,
|
||||||
|
"/hqs": <HQs />,
|
||||||
|
"/market": <Market />,
|
||||||
|
"/locations": <Locations />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Reads the game clock (the engine's device clock, CR-014), then ticks it every second. */
|
/** Reads the game clock (the engine's device clock, CR-014), then ticks it every second. */
|
||||||
|
|
@ -55,7 +64,11 @@ export function App() {
|
||||||
const retry = () => void state.refetch();
|
const retry = () => void state.refetch();
|
||||||
|
|
||||||
if (state.isPending) {
|
if (state.isPending) {
|
||||||
return <p className="grid min-h-dvh place-items-center font-display text-ink-400">Loading…</p>;
|
return (
|
||||||
|
<p className="grid min-h-dvh place-items-center font-display tracking-[0.3em] text-ink-400 uppercase">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state.data === undefined) {
|
if (state.data === undefined) {
|
||||||
// The first load failed: nothing to show yet.
|
// The first load failed: nothing to show yet.
|
||||||
|
|
@ -103,13 +116,7 @@ export function App() {
|
||||||
element={SCREEN_COMPONENTS[screen.path]}
|
element={SCREEN_COMPONENTS[screen.path]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Screens without a tab of their own (decision 0.5.167). */}
|
{/* Settings opens from the header gear, not a tab (CR-011). */}
|
||||||
<Route path="/map" element={<MapScreen />} />
|
|
||||||
<Route path="/pilots" element={<Pilots />} />
|
|
||||||
<Route path="/hqs" element={<HQs />} />
|
|
||||||
<Route path="/market" element={<Market />} />
|
|
||||||
<Route path="/locations" element={<Locations />} />
|
|
||||||
{/* Settings opens from the header gear and More, not a tab (CR-011). */}
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
{/* The Logbook opens from "While you were away" and the Bridge (CR-025). */}
|
{/* The Logbook opens from "While you were away" and the Bridge (CR-025). */}
|
||||||
<Route path="/logbook" element={<Logbook />} />
|
<Route path="/logbook" element={<Logbook />} />
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,11 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === "starting") {
|
if (status === "starting") {
|
||||||
return <p className="grid min-h-dvh place-items-center font-display text-ink-400">Loading…</p>;
|
return (
|
||||||
|
<p className="grid min-h-dvh place-items-center font-display tracking-[0.3em] text-ink-400 uppercase">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (status === "elsewhere") {
|
if (status === "elsewhere") {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,7 @@ export function AwaySummary() {
|
||||||
<ul aria-label="What happened" className="space-y-1.5">
|
<ul aria-label="What happened" className="space-y-1.5">
|
||||||
{summary.direct.map((entry) => (
|
{summary.direct.map((entry) => (
|
||||||
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
|
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
|
||||||
<Icon name="send" className="mt-0.5 size-4 shrink-0 text-ink-300" />
|
<Icon name="send" className="mt-0.5 size-4 shrink-0 text-cyan-300" />
|
||||||
{directText(entry as LogEntry, state)}
|
{directText(entry as LogEntry, state)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -302,7 +302,7 @@ export function AwaySummary() {
|
||||||
{highlights.length > 0 && (
|
{highlights.length > 0 && (
|
||||||
<ul aria-label="Highlights" className="space-y-1.5">
|
<ul aria-label="Highlights" className="space-y-1.5">
|
||||||
{highlights.map((h, i) => (
|
{highlights.map((h, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-ink-100">
|
<li key={i} className="flex items-start gap-2 text-amber-100">
|
||||||
<Icon
|
<Icon
|
||||||
name={h.kind === "problem" ? "alert" : "star"}
|
name={h.kind === "problem" ? "alert" : "star"}
|
||||||
className="mt-0.5 size-4 shrink-0 text-amber-300"
|
className="mt-0.5 size-4 shrink-0 text-amber-300"
|
||||||
|
|
@ -346,17 +346,19 @@ function Totals({ summary }: { summary: Summary }) {
|
||||||
[words.totals.deliveries, String(deliveries), "text-ink-50"],
|
[words.totals.deliveries, String(deliveries), "text-ink-50"],
|
||||||
];
|
];
|
||||||
if (discoveries > 0)
|
if (discoveries > 0)
|
||||||
figures.push([words.totals.discoveries, String(discoveries), "text-ink-100"]);
|
figures.push([words.totals.discoveries, String(discoveries), "text-cyan-200"]);
|
||||||
if (loanRepaid > 0)
|
if (loanRepaid > 0)
|
||||||
figures.push([words.totals.loanRepaid, formatCredits(loanRepaid), "text-ink-50"]);
|
figures.push([words.totals.loanRepaid, formatCredits(loanRepaid), "text-ink-50"]);
|
||||||
return (
|
return (
|
||||||
<dl
|
<dl
|
||||||
aria-label="Company totals"
|
aria-label="Company totals"
|
||||||
className="grid grid-flow-col gap-2 rounded-xl border border-glass-line bg-white/5 p-3"
|
className="grid grid-flow-col gap-2 rounded-xl border border-cyan-400/30 bg-cyan-950/30 p-3"
|
||||||
>
|
>
|
||||||
{figures.map(([label, value, tone]) => (
|
{figures.map(([label, value, tone]) => (
|
||||||
<div key={label}>
|
<div key={label}>
|
||||||
<dt className="font-display text-xs text-ink-400">{label}</dt>
|
<dt className="font-display text-[10px] tracking-wider text-ink-400 uppercase">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
<dd className={`font-display text-base font-bold tabular-nums ${tone}`}>{value}</dd>
|
<dd className={`font-display text-base font-bold tabular-nums ${tone}`}>{value}</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -380,7 +382,7 @@ function PilotLines({ summary }: { summary: Summary }) {
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{line.leveledUp && (
|
{line.leveledUp && (
|
||||||
<span className="shrink-0 rounded-full bg-amber-500/20 px-1.5 text-xs font-semibold text-ink-100">
|
<span className="shrink-0 rounded-full bg-amber-500/20 px-1.5 text-[10px] font-semibold text-amber-200 uppercase">
|
||||||
{words.leveledUp}
|
{words.leveledUp}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -56,23 +56,27 @@ export function DealModal() {
|
||||||
of every job.
|
of every job.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-black/25 p-3 text-sm ring-1 ring-amber-400/25">
|
<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-xs text-ink-400">You get</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
You get
|
||||||
|
</dt>
|
||||||
<dd aria-label="Ships and bay">
|
<dd aria-label="Ships and bay">
|
||||||
A {courier.name} for you, a {clipper.name} for {veteran.name}, and a bay at {bay.name}
|
A {courier.name} for you, a {clipper.name} for {veteran.name}, and a bay at {bay.name}
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="font-display text-xs text-ink-400">Pilot</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Pilot</dt>
|
||||||
<dd aria-label="Veteran pilot">
|
<dd aria-label="Veteran pilot">
|
||||||
…and {veteran.name}. {veteran.bio}
|
…and {veteran.name}. {veteran.bio}
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="font-display text-xs text-ink-400">Loan</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Loan</dt>
|
||||||
<dd
|
<dd
|
||||||
aria-label="Loan amount"
|
aria-label="Loan amount"
|
||||||
className="font-display font-semibold text-ink-100 tabular-nums"
|
className="font-display font-semibold text-amber-200 tabular-nums"
|
||||||
>
|
>
|
||||||
{formatCredits(dealLoanAmount)}
|
{formatCredits(dealLoanAmount)}
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="font-display text-xs text-ink-400">Repayment</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Repayment
|
||||||
|
</dt>
|
||||||
<dd aria-label="Repayment share">
|
<dd aria-label="Repayment share">
|
||||||
{Math.round(loanRepaymentShare * 100)}% of every payout until it's paid off
|
{Math.round(loanRepaymentShare * 100)}% of every payout until it's paid off
|
||||||
</dd>
|
</dd>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function SleepBanner() {
|
||||||
const { sleepsAt, afterHours } = state.fleetSleep;
|
const { sleepsAt, afterHours } = state.fleetSleep;
|
||||||
return (
|
return (
|
||||||
<Panel tone="amber" className="text-sm" aria-label="Fleet asleep">
|
<Panel tone="amber" className="text-sm" aria-label="Fleet asleep">
|
||||||
<p className="flex items-start gap-2 text-ink-100">
|
<p className="flex items-start gap-2 text-amber-100">
|
||||||
<Icon name="rest" className="mt-0.5 size-4 shrink-0" />
|
<Icon name="rest" className="mt-0.5 size-4 shrink-0" />
|
||||||
Your fleet went quiet on {formatSleepTime(sleepsAt)} after {sleepLabel(afterHours)} without
|
Your fleet went quiet on {formatSleepTime(sleepsAt)} after {sleepLabel(afterHours)} without
|
||||||
orders.
|
orders.
|
||||||
|
|
@ -63,7 +63,7 @@ export function FleetAsleep() {
|
||||||
if (!fleetQuiet(state)) return null;
|
if (!fleetQuiet(state)) return null;
|
||||||
return (
|
return (
|
||||||
<Panel tone="amber" className="text-sm" aria-label="Fleet asleep">
|
<Panel tone="amber" className="text-sm" aria-label="Fleet asleep">
|
||||||
<p className="flex items-center gap-2 font-display font-semibold text-ink-100">
|
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-amber-100">
|
||||||
<Icon name="rest" className="size-4 shrink-0" />
|
<Icon name="rest" className="size-4 shrink-0" />
|
||||||
Fleet asleep — waiting for your orders
|
Fleet asleep — waiting for your orders
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { NavLink } from "react-router";
|
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
|
||||||
|
|
||||||
/** Fleet's two views, Ships and Crew (decision 0.5.167). */
|
|
||||||
export function FleetViews() {
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
return (
|
|
||||||
<Views
|
|
||||||
label="Fleet views"
|
|
||||||
views={[
|
|
||||||
{ to: "/fleet", label: `Ships ${state.ships.length}` },
|
|
||||||
{ to: "/pilots", label: `Crew ${state.pilots.length}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Places and HQs, under More (decision 0.5.167). */
|
|
||||||
export function PlacesViews() {
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
return (
|
|
||||||
<Views
|
|
||||||
label="Places views"
|
|
||||||
views={[
|
|
||||||
{ to: "/locations", label: "Places" },
|
|
||||||
{ to: "/hqs", label: `HQs ${state.bays.length}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Views({ label, views }: { label: string; views: { to: string; label: string }[] }) {
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label={label}
|
|
||||||
className="flex gap-0.5 rounded-xl border border-glass-line bg-black/25 p-0.5"
|
|
||||||
>
|
|
||||||
{views.map((view) => (
|
|
||||||
<NavLink
|
|
||||||
key={view.to}
|
|
||||||
to={view.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex min-h-11 flex-1 items-center justify-center rounded-[0.6rem] text-sm font-medium ${
|
|
||||||
isActive ? "bg-white/14 text-ink-50" : "text-ink-300 hover:text-ink-50"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{view.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -54,7 +54,7 @@ export function FuelGauge({ ship }: { ship: Ship }) {
|
||||||
label={`${ship.name} fuel`}
|
label={`${ship.name} fuel`}
|
||||||
value={ship.fuelFu}
|
value={ship.fuelFu}
|
||||||
max={capacity}
|
max={capacity}
|
||||||
tone={percent < 25 ? "rose" : "default"}
|
tone={percent < 25 ? "rose" : "amber"}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
|
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
|
||||||
|
|
@ -183,7 +183,7 @@ export function ShipFuel({ shipId, onClose }: { shipId: string; onClose: () => v
|
||||||
placeholder="Amount (fu)"
|
placeholder="Amount (fu)"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
className="w-full rounded-lg border border-glass-line bg-black/25 px-3 py-2 text-ink-50 focus:border-action focus:outline-none"
|
className="w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-ink-50 focus:border-amber-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -202,11 +202,11 @@ export function ShipFuel({ shipId, onClose }: { shipId: string; onClose: () => v
|
||||||
|
|
||||||
<fieldset className="mt-4 text-sm">
|
<fieldset className="mt-4 text-sm">
|
||||||
<legend className="text-ink-400">Refuel policy</legend>
|
<legend className="text-ink-400">Refuel policy</legend>
|
||||||
<div className="mt-1 flex overflow-hidden rounded-lg border border-glass-line">
|
<div className="mt-1 flex overflow-hidden rounded-lg border border-space-600">
|
||||||
{(["manual", "auto"] as const).map((value) => (
|
{(["manual", "auto"] as const).map((value) => (
|
||||||
<label
|
<label
|
||||||
key={value}
|
key={value}
|
||||||
className="flex-1 cursor-pointer px-3 py-1.5 text-center has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
|
className="flex-1 cursor-pointer px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ export function GoalsCard() {
|
||||||
if (next.length === 0) return null;
|
if (next.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel as="section" id="goals" aria-labelledby="goals-title">
|
<Panel as="section" feature id="goals" aria-labelledby="goals-title">
|
||||||
<h2 id="goals-title" className="flex items-center gap-2 text-sm font-semibold">
|
<h2 id="goals-title" className="flex items-center gap-2 font-display text-lg font-bold">
|
||||||
<Icon name="target" className="size-4 text-ink-300" />
|
<Icon name="target" className="size-5 text-amber-300" />
|
||||||
Goals
|
Goals
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
|
|
@ -28,21 +28,23 @@ export function GoalsCard() {
|
||||||
<li
|
<li
|
||||||
key={goal.id}
|
key={goal.id}
|
||||||
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
|
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||||
ready ? "border-earned/45 bg-earned/8" : "border-glass-line"
|
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 className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`grid size-5 shrink-0 place-items-center rounded-full ring-1 ${
|
className={`grid size-5 shrink-0 place-items-center rounded-full ring-1 ${
|
||||||
ready ? "bg-earned text-space-950 ring-earned" : "ring-ink-400"
|
ready ? "bg-emerald-400 text-space-950 ring-emerald-300" : "ring-space-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{ready && <Icon name="check" className="size-3.5" />}
|
{ready && <Icon name="check" className="size-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
<span className={"text-ink-100"}>
|
<span className={ready ? "text-emerald-200" : "text-ink-200"}>
|
||||||
{details.title}
|
{details.title}
|
||||||
<span className="ml-2 text-xs font-semibold whitespace-nowrap text-earned">
|
<span className="ml-2 font-display text-xs font-semibold whitespace-nowrap text-amber-300/90">
|
||||||
+{formatCredits(details.reward)}
|
+{formatCredits(details.reward)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -53,7 +55,7 @@ export function GoalsCard() {
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={claim.isPending}
|
disabled={claim.isPending}
|
||||||
onClick={() => claim.mutate({ goalId: goal.id })}
|
onClick={() => claim.mutate({ goalId: goal.id })}
|
||||||
className="shrink-0"
|
className="shrink-0 animate-glow"
|
||||||
>
|
>
|
||||||
Claim
|
Claim
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { goodName } from "./Goods";
|
||||||
import { serviceStatus } from "./Service";
|
import { serviceStatus } from "./Service";
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"rounded-lg border border-glass-line bg-black/25 px-3 py-2 text-sm text-ink-50 focus:border-action focus:outline-none";
|
"rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none";
|
||||||
|
|
||||||
/** The one-time Dispatch Office on the Bridge (decision 0.5.123). */
|
/** The one-time Dispatch Office on the Bridge (decision 0.5.123). */
|
||||||
export function DispatchOfficePanel() {
|
export function DispatchOfficePanel() {
|
||||||
|
|
@ -22,14 +22,14 @@ export function DispatchOfficePanel() {
|
||||||
if (state.dispatchOffice.owned) {
|
if (state.dispatchOffice.owned) {
|
||||||
return (
|
return (
|
||||||
<Panel tone="cyan" className="text-sm" aria-label="Dispatch Office">
|
<Panel tone="cyan" className="text-sm" aria-label="Dispatch Office">
|
||||||
<p className="flex items-center gap-2 font-display font-semibold text-ink-100">
|
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-cyan-100">
|
||||||
<Icon name="send" className="size-4" />
|
<Icon name="send" className="size-4" />
|
||||||
Dispatch Office
|
Dispatch Office
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-ink-300">
|
<p className="mt-1 text-ink-300">
|
||||||
Your hired pilots can run jobs on their own. Set them to Independent in{" "}
|
Your hired pilots can run jobs on their own. Set them to Independent on{" "}
|
||||||
<Link to="/pilots" className="font-semibold underline">
|
<Link to="/pilots" className="font-semibold text-amber-300 underline">
|
||||||
Crew
|
Pilots
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -37,15 +37,13 @@ export function DispatchOfficePanel() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Panel className="text-sm" aria-label="Dispatch Office">
|
<Panel tone="amber" feature className="text-sm" aria-label="Dispatch Office">
|
||||||
<p className="font-semibold">Dispatch Office</p>
|
<p className="font-display font-semibold tracking-wide text-amber-100">Dispatch Office</p>
|
||||||
<p className="mt-1 text-ink-300">
|
<p className="mt-1 text-ink-300">
|
||||||
Lets your hired pilots run jobs on their own, so the company keeps earning while you're
|
Lets your hired pilots run jobs on their own, so the company keeps earning while you're
|
||||||
away.
|
away.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
disabled={state.company.credits < price || buy.isPending}
|
disabled={state.company.credits < price || buy.isPending}
|
||||||
onClick={() => buy.mutate({})}
|
onClick={() => buy.mutate({})}
|
||||||
|
|
@ -100,14 +98,16 @@ export function PilotIndependence({ pilot }: { pilot: Pilot }) {
|
||||||
const status = independentStatus(state, pilot);
|
const status = independentStatus(state, pilot);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-lg bg-black/25 p-2.5 ring-1 ring-glass-line">
|
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="font-display text-xs text-ink-400">Flies</legend>
|
<legend className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
<div className="mt-1 flex overflow-hidden rounded-lg border border-glass-line">
|
Flies
|
||||||
|
</legend>
|
||||||
|
<div className="mt-1 flex overflow-hidden rounded-lg border border-space-600">
|
||||||
{(["manual", "independent"] as const).map((mode) => (
|
{(["manual", "independent"] as const).map((mode) => (
|
||||||
<label
|
<label
|
||||||
key={mode}
|
key={mode}
|
||||||
className={`flex-1 px-3 py-1.5 text-center has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2 ${owned ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}
|
className={`flex-1 px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2 ${owned ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|
@ -129,7 +129,7 @@ export function PilotIndependence({ pilot }: { pilot: Pilot }) {
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{status && (
|
{status && (
|
||||||
<p aria-label={`${pilot.name} status`} className="mt-2 text-ink-100">
|
<p aria-label={`${pilot.name} status`} className="mt-2 text-cyan-100">
|
||||||
{status}
|
{status}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -155,7 +155,9 @@ function OrdersEditor({ pilot }: { pilot: Pilot }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className="mt-2">
|
<details className="mt-2">
|
||||||
<summary className="cursor-pointer font-display text-xs text-ink-100">Orders</summary>
|
<summary className="cursor-pointer font-display text-xs tracking-wider text-amber-200 uppercase">
|
||||||
|
Orders
|
||||||
|
</summary>
|
||||||
<form
|
<form
|
||||||
aria-label={`Orders for ${pilot.name}`}
|
aria-label={`Orders for ${pilot.name}`}
|
||||||
className="mt-2 space-y-3 text-sm"
|
className="mt-2 space-y-3 text-sm"
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,11 @@ export function LoanCard() {
|
||||||
return (
|
return (
|
||||||
<Panel>
|
<Panel>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<h2 className="text-sm font-semibold">Loan from Halvorsen</h2>
|
<h2 className="font-display text-lg font-bold">Loan from Halvorsen</h2>
|
||||||
<span aria-label="Loan balance" className="font-semibold tabular-nums">
|
<span
|
||||||
|
aria-label="Loan balance"
|
||||||
|
className="font-display font-semibold text-amber-200 tabular-nums"
|
||||||
|
>
|
||||||
{formatCredits(loanBalance)} left
|
{formatCredits(loanBalance)} left
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -36,7 +39,9 @@ export function LoanCard() {
|
||||||
until it's paid off.
|
until it's paid off.
|
||||||
</p>
|
</p>
|
||||||
<Meter label="Loan paid off" value={paidOff} tone="emerald" className="mt-3" />
|
<Meter label="Loan paid off" value={paidOff} tone="emerald" className="mt-3" />
|
||||||
<p className="mt-1 text-right text-xs text-ink-300">{paidOff}% paid off</p>
|
<p className="mt-1 text-right font-display text-xs text-emerald-300/80">
|
||||||
|
{paidOff}% paid off
|
||||||
|
</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="mt-2 flex items-center gap-2"
|
className="mt-2 flex items-center gap-2"
|
||||||
|
|
@ -54,13 +59,9 @@ export function LoanCard() {
|
||||||
placeholder="Amount"
|
placeholder="Amount"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
className="min-h-11 min-w-0 flex-1 rounded-xl border border-glass-line bg-black/25 px-3 tabular-nums placeholder:text-ink-400 focus:border-action focus:outline-none"
|
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"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button type="submit" disabled={input === "" || problem !== null || repay.isPending}>
|
||||||
type="submit"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={input === "" || problem !== null || repay.isPending}
|
|
||||||
>
|
|
||||||
Pay down
|
Pay down
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
import { content } from "@deadhead/content";
|
|
||||||
import type { PlayerState } from "@deadhead/schema";
|
|
||||||
import { Link } from "react-router";
|
|
||||||
import { formatClock, formatCredits, locationName } from "../format";
|
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
|
||||||
import { Icon } from "../ui";
|
|
||||||
import { readyShips } from "../game";
|
|
||||||
import type { ScreenPath } from "../routes";
|
|
||||||
import { Countdown } from "./Countdown";
|
|
||||||
import { useRadioLater } from "./RadioCalls";
|
|
||||||
|
|
||||||
/** One thing waiting on the player, and the tab whose badge counts it (decision 0.5.168). */
|
|
||||||
export type NeedsYouItem =
|
|
||||||
| {
|
|
||||||
kind: "radio";
|
|
||||||
tab: ScreenPath;
|
|
||||||
count: 1;
|
|
||||||
dispatchId: string;
|
|
||||||
eventId: string;
|
|
||||||
pilotId: string;
|
|
||||||
endsAt: number;
|
|
||||||
}
|
|
||||||
| { kind: "ready"; tab: ScreenPath; count: 1; name: string; locationId: string | null }
|
|
||||||
| { kind: "skills"; tab: ScreenPath; count: number }
|
|
||||||
| { kind: "goal"; tab: ScreenPath; count: 1; goalId: string };
|
|
||||||
|
|
||||||
/** Everything waiting on the player, most urgent first. */
|
|
||||||
export function needsYou(state: PlayerState): NeedsYouItem[] {
|
|
||||||
const items: NeedsYouItem[] = state.radioCalls.map((call) => ({
|
|
||||||
kind: "radio",
|
|
||||||
tab: "/",
|
|
||||||
count: 1,
|
|
||||||
dispatchId: call.dispatchId,
|
|
||||||
eventId: call.eventId,
|
|
||||||
pilotId: call.pilotId,
|
|
||||||
endsAt: call.windowEndsAt,
|
|
||||||
}));
|
|
||||||
if (state.tutorial) {
|
|
||||||
// The company loaner isn't in the ships list.
|
|
||||||
if (!state.tutorial.runInFlight) {
|
|
||||||
items.push({
|
|
||||||
kind: "ready",
|
|
||||||
tab: "/contracts",
|
|
||||||
count: 1,
|
|
||||||
name: "Company loaner",
|
|
||||||
locationId: state.tutorial.loanerLocationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const ship of readyShips(state)) {
|
|
||||||
items.push({
|
|
||||||
kind: "ready",
|
|
||||||
tab: "/contracts",
|
|
||||||
count: 1,
|
|
||||||
name: ship.name,
|
|
||||||
locationId: ship.locationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const points = state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0);
|
|
||||||
if (points > 0) items.push({ kind: "skills", tab: "/fleet", count: points });
|
|
||||||
for (const goal of state.goals.filter((g) => g.status === "ready")) {
|
|
||||||
items.push({ kind: "goal", tab: "/", count: 1, goalId: goal.id });
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROW = "flex min-h-14 w-full items-center gap-3 py-2 text-left";
|
|
||||||
|
|
||||||
function Dot({ tone }: { tone: "problem" | "action" | "earned" | "ink" }) {
|
|
||||||
const fill = {
|
|
||||||
problem: "bg-problem",
|
|
||||||
action: "bg-action",
|
|
||||||
earned: "bg-earned",
|
|
||||||
ink: "bg-ink-300",
|
|
||||||
}[tone];
|
|
||||||
return <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${fill}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RowText({ title, detail }: { title: string; detail: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block leading-snug font-medium">{title}</span>
|
|
||||||
<span className="block text-[13px] leading-snug text-ink-300">{detail}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Go = () => <Icon name="arrow" className="size-4 shrink-0 text-ink-400" />;
|
|
||||||
|
|
||||||
/** The Bridge's list of what needs the player (decision 0.5.168). */
|
|
||||||
export function NeedsYou() {
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
const open = useRadioLater((s) => s.open);
|
|
||||||
const items = needsYou(state);
|
|
||||||
const radio = items.filter((i) => i.kind === "radio");
|
|
||||||
const rest = items.filter((i) => i.kind !== "radio");
|
|
||||||
const pilot = (id: string) => state.pilots.find((p) => p.id === id)?.name ?? "Your pilot";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-labelledby="needs-you-title">
|
|
||||||
<h2 id="needs-you-title" className="text-sm font-semibold">
|
|
||||||
Needs you
|
|
||||||
</h2>
|
|
||||||
{items.length === 0 && (
|
|
||||||
<p className="py-2 text-sm text-ink-300">Nothing right now. Your fleet has it in hand.</p>
|
|
||||||
)}
|
|
||||||
{radio.length > 0 && (
|
|
||||||
<ul aria-label="Radio calls" className="divide-y divide-glass-line">
|
|
||||||
{radio.map((call) => (
|
|
||||||
<li key={call.dispatchId} className={ROW}>
|
|
||||||
<Dot tone="problem" />
|
|
||||||
<RowText
|
|
||||||
title={content.tripEvent(call.eventId)?.title ?? "Radio call"}
|
|
||||||
detail={
|
|
||||||
<>
|
|
||||||
{pilot(call.pilotId)} ·{" "}
|
|
||||||
<Countdown to={call.endsAt} format={formatClock} done="now" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => open(call.dispatchId)}
|
|
||||||
className="min-h-11 shrink-0 rounded-xl bg-action px-3 text-sm font-semibold text-on-action"
|
|
||||||
>
|
|
||||||
Answer
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{rest.length > 0 && (
|
|
||||||
<ul aria-label="Other things waiting" className="divide-y divide-glass-line">
|
|
||||||
{rest.map((item) => {
|
|
||||||
if (item.kind === "ready") {
|
|
||||||
const to = state.tutorial
|
|
||||||
? "/contracts"
|
|
||||||
: `/contracts?location=${item.locationId ?? ""}`;
|
|
||||||
return (
|
|
||||||
<li key={`ready-${item.name}`}>
|
|
||||||
<Link to={to} className={ROW}>
|
|
||||||
<Dot tone="action" />
|
|
||||||
<RowText
|
|
||||||
title={`${item.name} is ready`}
|
|
||||||
detail={`At ${locationName(item.locationId ?? "")} · find it a job`}
|
|
||||||
/>
|
|
||||||
<Go />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (item.kind === "skills") {
|
|
||||||
const who = state.pilots
|
|
||||||
.filter((p) => p.skillPointsAvailable > 0)
|
|
||||||
.map((p) => `${p.isAvatar ? "You" : p.name} ${p.skillPointsAvailable}`)
|
|
||||||
.join(" · ");
|
|
||||||
return (
|
|
||||||
<li key="skills">
|
|
||||||
<Link to="/pilots" className={ROW}>
|
|
||||||
<Dot tone="ink" />
|
|
||||||
<RowText
|
|
||||||
title={`${item.count} skill ${item.count === 1 ? "point" : "points"} to spend`}
|
|
||||||
detail={who}
|
|
||||||
/>
|
|
||||||
<Go />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (item.kind === "goal") {
|
|
||||||
const goal = content.onboardingGoal(item.goalId);
|
|
||||||
return (
|
|
||||||
<li key={item.goalId}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
document.getElementById("goals")?.scrollIntoView?.({ behavior: "smooth" })
|
|
||||||
}
|
|
||||||
className={ROW}
|
|
||||||
>
|
|
||||||
<Dot tone="earned" />
|
|
||||||
<RowText
|
|
||||||
title={`Goal ready: +${formatCredits(goal?.reward ?? 0)}`}
|
|
||||||
detail={goal?.title ?? ""}
|
|
||||||
/>
|
|
||||||
<Go />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -100,12 +100,14 @@ function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
|
||||||
{locationName(from)} → {locationName(to)}
|
{locationName(from)} → {locationName(to)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
const fuel = (cost: number) => <span className="text-ink-100">fuel {formatCredits(cost)}</span>;
|
const fuel = (cost: number) => (
|
||||||
|
<span className="text-amber-200/90">fuel {formatCredits(cost)}</span>
|
||||||
|
);
|
||||||
switch (entry.kind) {
|
switch (entry.kind) {
|
||||||
case "flight":
|
case "flight":
|
||||||
return entry.deadhead ? (
|
return entry.deadhead ? (
|
||||||
<>
|
<>
|
||||||
<Icon name="send" className="size-4 text-ink-300" />
|
<Icon name="send" className="size-4 text-cyan-300" />
|
||||||
<span>Deadhead</span>
|
<span>Deadhead</span>
|
||||||
{route(entry.originId, entry.destinationId)}
|
{route(entry.originId, entry.destinationId)}
|
||||||
{fuel(entry.fuelCost)}
|
{fuel(entry.fuelCost)}
|
||||||
|
|
@ -124,61 +126,61 @@ function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
|
||||||
{entry.loanRepayment > 0 && (
|
{entry.loanRepayment > 0 && (
|
||||||
<span className="text-ink-400">lender {formatCredits(entry.loanRepayment)}</span>
|
<span className="text-ink-400">lender {formatCredits(entry.loanRepayment)}</span>
|
||||||
)}
|
)}
|
||||||
{entry.xp > 0 && <span className="text-ink-300">+{entry.xp} XP</span>}
|
{entry.xp > 0 && <span className="text-violet-300">+{entry.xp} XP</span>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "refuel":
|
case "refuel":
|
||||||
return (
|
return (
|
||||||
<span className="text-ink-100">
|
<span className="text-amber-200/90">
|
||||||
{Math.round(entry.boughtFu)} fu at {locationName(entry.locationId)},{" "}
|
{Math.round(entry.boughtFu)} fu at {locationName(entry.locationId)},{" "}
|
||||||
{formatCredits(entry.cost)} ({REFUEL_SOURCE[entry.source]})
|
{formatCredits(entry.cost)} ({REFUEL_SOURCE[entry.source]})
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "rest":
|
case "rest":
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-ink-300">
|
<span className="flex items-center gap-1 text-sky-300">
|
||||||
<Icon name="rest" className="size-4" />
|
<Icon name="rest" className="size-4" />
|
||||||
Rested until {formatLocalTime(entry.until)}
|
Rested until {formatLocalTime(entry.until)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
return (
|
return (
|
||||||
<span className="text-ink-100">
|
<span className="text-cyan-100">
|
||||||
{shipName(state, entry.shipId)}:{" "}
|
{shipName(state, entry.shipId)}:{" "}
|
||||||
{content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId} level {entry.level} finished
|
{content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId} level {entry.level} finished
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "discovery":
|
case "discovery":
|
||||||
return <span className="text-ink-100">Discovered {locationName(entry.locationId)}</span>;
|
return <span className="text-cyan-200">Discovered {locationName(entry.locationId)}</span>;
|
||||||
case "level-up":
|
case "level-up":
|
||||||
return (
|
return (
|
||||||
<span className="text-ink-100">
|
<span className="text-amber-200">
|
||||||
Reached level {entry.level} · {entry.skillPoints}{" "}
|
Reached level {entry.level} · {entry.skillPoints}{" "}
|
||||||
{entry.skillPoints === 1 ? "skill point" : "skill points"}
|
{entry.skillPoints === 1 ? "skill point" : "skill points"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "event":
|
case "event":
|
||||||
return (
|
return (
|
||||||
<span className={entry.good ? "text-emerald-200" : "text-ink-100"}>
|
<span className={entry.good ? "text-emerald-200" : "text-amber-200"}>
|
||||||
{eventResultText(entry, state)}
|
{eventResultText(entry, state)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "repair":
|
case "repair":
|
||||||
return (
|
return (
|
||||||
<span className="text-ink-100">
|
<span className="text-cyan-100">
|
||||||
{shipName(state, entry.shipId)}: hull repaired from {entry.fromPct}% to 100%,{" "}
|
{shipName(state, entry.shipId)}: hull repaired from {entry.fromPct}% to 100%,{" "}
|
||||||
{formatCredits(entry.cost)}
|
{formatCredits(entry.cost)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "service":
|
case "service":
|
||||||
return (
|
return (
|
||||||
<span className={entry.phase === "waiting-funds" ? "text-ink-100" : "text-ink-100"}>
|
<span className={entry.phase === "waiting-funds" ? "text-amber-200" : "text-cyan-100"}>
|
||||||
{serviceEntryText(entry, shipName(state, entry.shipId))}
|
{serviceEntryText(entry, shipName(state, entry.shipId))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "goal-ready":
|
case "goal-ready":
|
||||||
return (
|
return (
|
||||||
<span className="text-ink-100">
|
<span className="text-amber-200">
|
||||||
Goal ready: {content.onboardingGoal(entry.goalId)?.title ?? entry.goalId}
|
Goal ready: {content.onboardingGoal(entry.goalId)?.title ?? entry.goalId}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -199,13 +201,13 @@ function PilotSection({ log, state, open }: { log: PilotLog; state: PlayerState;
|
||||||
const [shown, setShown] = useState(PAGE);
|
const [shown, setShown] = useState(PAGE);
|
||||||
const now = useClock((c) => c.now);
|
const now = useClock((c) => c.now);
|
||||||
return (
|
return (
|
||||||
<details open={open} className="rounded-xl border border-glass-line bg-black/25 p-3">
|
<details open={open} className="rounded-xl border border-space-600 bg-space-950/50 p-3">
|
||||||
<summary className="flex min-h-11 cursor-pointer items-center justify-between gap-2">
|
<summary className="flex cursor-pointer items-baseline justify-between gap-2">
|
||||||
<span className="font-display font-bold">{log.name}</span>
|
<span className="font-display font-bold tracking-wide">{log.name}</span>
|
||||||
{log.xp > 0 && <span className="text-xs text-ink-300">+{log.xp} XP</span>}
|
{log.xp > 0 && <span className="text-xs text-violet-300">+{log.xp} XP</span>}
|
||||||
</summary>
|
</summary>
|
||||||
{log.now && (
|
{log.now && (
|
||||||
<p aria-label={`${log.name} now`} className="mt-1 text-ink-100">
|
<p aria-label={`${log.name} now`} className="mt-1 text-cyan-100">
|
||||||
{log.now}
|
{log.now}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -238,18 +240,18 @@ export function PilotLogbook({ logbook, state }: { logbook: Logbook; state: Play
|
||||||
<section aria-label="Pilot logbook" className="space-y-3 text-sm">
|
<section aria-label="Pilot logbook" className="space-y-3 text-sm">
|
||||||
<div
|
<div
|
||||||
aria-label="Logbook totals"
|
aria-label="Logbook totals"
|
||||||
className="grid grid-cols-2 gap-x-3 gap-y-1 rounded-xl border border-glass-line bg-white/5 p-3 sm:grid-cols-4"
|
className="grid grid-cols-2 gap-x-3 gap-y-1 rounded-xl border border-cyan-400/40 bg-cyan-950/30 p-3 sm:grid-cols-4"
|
||||||
>
|
>
|
||||||
<span>{totals.jobs} jobs</span>
|
<span>{totals.jobs} jobs</span>
|
||||||
<span>{totals.deadheads} deadheads</span>
|
<span>{totals.deadheads} deadheads</span>
|
||||||
<span className="text-emerald-300">{formatCredits(totals.payout)} paid</span>
|
<span className="text-emerald-300">{formatCredits(totals.payout)} paid</span>
|
||||||
<span className="text-ink-300">{formatCredits(totals.pilotShare)} pilot shares</span>
|
<span className="text-ink-300">{formatCredits(totals.pilotShare)} pilot shares</span>
|
||||||
<span className="text-ink-100">{formatCredits(totals.fuelCost)} fuel</span>
|
<span className="text-amber-200">{formatCredits(totals.fuelCost)} fuel</span>
|
||||||
<span className="text-ink-300">{formatCredits(totals.loanRepaid)} to the lender</span>
|
<span className="text-ink-300">{formatCredits(totals.loanRepaid)} to the lender</span>
|
||||||
<span className={totals.net < 0 ? "text-rose-300" : "text-emerald-300"}>
|
<span className={totals.net < 0 ? "text-rose-300" : "text-emerald-300"}>
|
||||||
{formatCredits(totals.net)} net
|
{formatCredits(totals.net)} net
|
||||||
</span>
|
</span>
|
||||||
<span className="text-ink-300">+{totals.xp} XP</span>
|
<span className="text-violet-300">+{totals.xp} XP</span>
|
||||||
</div>
|
</div>
|
||||||
{logbook.pilots.length === 0 ? (
|
{logbook.pilots.length === 0 ? (
|
||||||
<p className="text-ink-400">Nothing logged in this time.</p>
|
<p className="text-ink-400">Nothing logged in this time.</p>
|
||||||
|
|
@ -285,7 +287,7 @@ export function CatchingUp({ busy, delayMs = 500 }: { busy: boolean; delayMs?: n
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
role="status"
|
role="status"
|
||||||
className="fixed top-3 left-1/2 z-40 -translate-x-1/2 rounded-full border border-glass-line bg-white/5 px-4 py-1.5 font-display text-sm text-ink-100 shadow-panel"
|
className="fixed top-3 left-1/2 z-40 -translate-x-1/2 rounded-full border border-cyan-400/50 bg-space-900/95 px-4 py-1.5 font-display text-sm text-cyan-100 shadow-panel"
|
||||||
>
|
>
|
||||||
Catching up on your fleet…
|
Catching up on your fleet…
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) {
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<p className="text-ink-100">{fill(def.text, { pilot })}</p>
|
<p className="text-ink-100">{fill(def.text, { pilot })}</p>
|
||||||
{ship && <p className="text-ink-400">{ship}</p>}
|
{ship && <p className="text-ink-400">{ship}</p>}
|
||||||
<p aria-label="Time to answer" className="text-ink-100">
|
<p aria-label="Time to answer" className="text-amber-200">
|
||||||
<Icon name="clock" className="mr-1.5 inline size-4 align-[-3px]" />
|
<Icon name="clock" className="mr-1.5 inline size-4 align-[-3px]" />
|
||||||
Answer within <Countdown to={call.windowEndsAt} format={formatClock} done="now" /> — then{" "}
|
Answer within <Countdown to={call.windowEndsAt} format={formatClock} done="now" /> — then{" "}
|
||||||
{pilot} will {fallback?.label.toLowerCase()}.
|
{pilot} will {fallback?.label.toLowerCase()}.
|
||||||
|
|
@ -86,7 +86,7 @@ function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) {
|
||||||
disabled={!available || answer.isPending}
|
disabled={!available || answer.isPending}
|
||||||
onClick={() => answer.mutate({ dispatchId: call.dispatchId, optionId: option.id })}
|
onClick={() => answer.mutate({ dispatchId: call.dispatchId, optionId: option.id })}
|
||||||
>
|
>
|
||||||
<span className="font-display">
|
<span className="font-display tracking-wide">
|
||||||
{option.label}
|
{option.label}
|
||||||
{option.id === call.defaultOptionId && " (if you don't answer)"}
|
{option.id === call.defaultOptionId && " (if you don't answer)"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -151,7 +151,7 @@ export function RadioCallsPanel() {
|
||||||
if (state.radioCalls.length === 0) return null;
|
if (state.radioCalls.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<Panel tone="amber" className="text-sm" aria-label="Radio calls">
|
<Panel tone="amber" className="text-sm" aria-label="Radio calls">
|
||||||
<p className="flex items-center gap-2 font-display font-semibold text-ink-100">
|
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-amber-100">
|
||||||
<Icon name="alert" className="size-4" />
|
<Icon name="alert" className="size-4" />
|
||||||
Radio calls ({state.radioCalls.length})
|
Radio calls ({state.radioCalls.length})
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function RouteStrip({
|
||||||
{percent === null ? (
|
{percent === null ? (
|
||||||
<Icon
|
<Icon
|
||||||
name="arrow"
|
name="arrow"
|
||||||
className="absolute top-1/2 left-1/2 size-4 -translate-1/2 rounded-full bg-space-900 text-ink-300"
|
className="absolute top-1/2 left-1/2 size-4 -translate-1/2 rounded-full bg-space-900 text-cyan-300"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -77,7 +77,7 @@ function Stop({ id, size }: { id: string; size: "sm" | "md" }) {
|
||||||
{/* CSS-drawn label: the route is already in text nearby, so keep it out of the DOM. */}
|
{/* CSS-drawn label: the route is already in text nearby, so keep it out of the DOM. */}
|
||||||
<span
|
<span
|
||||||
data-label={locationName(id)}
|
data-label={locationName(id)}
|
||||||
className="mt-0.5 max-w-full truncate font-display text-xs font-semibold text-ink-300 after:content-[attr(data-label)]"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export function ServiceTag({ ship }: { ship: Ship }) {
|
||||||
<p
|
<p
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={`${ship.name} service`}
|
aria-label={`${ship.name} service`}
|
||||||
className={`rounded-lg border px-3 py-1.5 text-sm ${warn ? "border-amber-400/40 bg-amber-950/30 text-ink-100" : "border-glass-line bg-white/5 text-ink-100"}`}
|
className={`rounded-lg border px-3 py-1.5 text-sm ${warn ? "border-amber-400/40 bg-amber-950/30 text-amber-100" : "border-cyan-400/40 bg-cyan-950/30 text-cyan-100"}`}
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -149,7 +149,7 @@ function BookingForm({ ship, onDone }: { ship: Ship; onDone: () => void }) {
|
||||||
checked={repair && canRepair}
|
checked={repair && canRepair}
|
||||||
disabled={!canRepair}
|
disabled={!canRepair}
|
||||||
onChange={(e) => setRepair(e.target.checked)}
|
onChange={(e) => setRepair(e.target.checked)}
|
||||||
className="size-4 accent-action"
|
className="size-4 accent-amber-400"
|
||||||
/>
|
/>
|
||||||
Repair to 100%
|
Repair to 100%
|
||||||
{!canRepair && <span className="text-ink-400">(hull is whole)</span>}
|
{!canRepair && <span className="text-ink-400">(hull is whole)</span>}
|
||||||
|
|
@ -160,7 +160,7 @@ function BookingForm({ ship, onDone }: { ship: Ship; onDone: () => void }) {
|
||||||
aria-label="Upgrade"
|
aria-label="Upgrade"
|
||||||
value={upgradeId ?? ""}
|
value={upgradeId ?? ""}
|
||||||
onChange={(e) => setUpgradeId(e.target.value || null)}
|
onChange={(e) => setUpgradeId(e.target.value || null)}
|
||||||
className="mt-1 block w-full rounded-lg border border-glass-line bg-space-950 px-2 py-1.5"
|
className="mt-1 block w-full rounded-lg border border-space-600 bg-space-950 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{upgrades.map((u) => (
|
{upgrades.map((u) => (
|
||||||
|
|
@ -176,7 +176,7 @@ function BookingForm({ ship, onDone }: { ship: Ship; onDone: () => void }) {
|
||||||
aria-label="HQ"
|
aria-label="HQ"
|
||||||
value={hqId}
|
value={hqId}
|
||||||
onChange={(e) => setHqId(e.target.value)}
|
onChange={(e) => setHqId(e.target.value)}
|
||||||
className="mt-1 block w-full rounded-lg border border-glass-line bg-space-950 px-2 py-1.5"
|
className="mt-1 block w-full rounded-lg border border-space-600 bg-space-950 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
{hqs.map((id) => (
|
{hqs.map((id) => (
|
||||||
<option key={id} value={id}>
|
<option key={id} value={id}>
|
||||||
|
|
@ -221,14 +221,14 @@ function AutoRepair({ ship }: { ship: Ship }) {
|
||||||
const { minPct, maxPct, stepPct } = content.config.autoRepair;
|
const { minPct, maxPct, stepPct } = content.config.autoRepair;
|
||||||
const { on, thresholdPct } = ship.autoRepair;
|
const { on, thresholdPct } = ship.autoRepair;
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-glass-line pt-3">
|
<div className="mt-3 border-t border-space-700 pt-3">
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={on}
|
checked={on}
|
||||||
disabled={save.isPending}
|
disabled={save.isPending}
|
||||||
onChange={(e) => save.mutate({ shipId: ship.id, on: e.target.checked, thresholdPct })}
|
onChange={(e) => save.mutate({ shipId: ship.id, on: e.target.checked, thresholdPct })}
|
||||||
className="size-4 accent-action"
|
className="size-4 accent-amber-400"
|
||||||
/>
|
/>
|
||||||
Auto-repair below {thresholdPct}%
|
Auto-repair below {thresholdPct}%
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -241,7 +241,7 @@ function AutoRepair({ ship }: { ship: Ship }) {
|
||||||
value={thresholdPct}
|
value={thresholdPct}
|
||||||
disabled={!on}
|
disabled={!on}
|
||||||
onChange={(e) => save.mutate({ shipId: ship.id, on, thresholdPct: Number(e.target.value) })}
|
onChange={(e) => save.mutate({ shipId: ship.id, on, thresholdPct: Number(e.target.value) })}
|
||||||
className="mt-2 w-full accent-action"
|
className="mt-2 w-full accent-amber-400"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-ink-400">
|
<p className="mt-1 text-xs text-ink-400">
|
||||||
With an Independent pilot, a repair is booked at the nearest HQ when the hull drops below
|
With an Independent pilot, a repair is booked at the nearest HQ when the hull drops below
|
||||||
|
|
@ -272,10 +272,10 @@ export function ServiceSection({ shipId }: { shipId: string }) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Service"
|
aria-label="Service"
|
||||||
className="mt-4 rounded-xl border border-glass-line bg-white/5 p-3 text-sm"
|
className="mt-4 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="font-display font-bold">Service</span>
|
<span className="font-display font-bold tracking-wide">Service</span>
|
||||||
{booking && (
|
{booking && (
|
||||||
<span className="text-ink-400">{booking.by === "auto" ? "Auto" : "Booked"}</span>
|
<span className="text-ink-400">{booking.by === "auto" ? "Auto" : "Booked"}</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export function SkillPointPrompt() {
|
||||||
{ready.map((pilot, i) => (
|
{ready.map((pilot, i) => (
|
||||||
<span key={pilot.id}>
|
<span key={pilot.id}>
|
||||||
{i > 0 && " · "}
|
{i > 0 && " · "}
|
||||||
<strong className="font-semibold text-ink-100">{callName(pilot)}:</strong>{" "}
|
<strong className="font-semibold text-cyan-200">{callName(pilot)}:</strong>{" "}
|
||||||
{pilot.skillPointsAvailable} {pilot.skillPointsAvailable === 1 ? "point" : "points"}
|
{pilot.skillPointsAvailable} {pilot.skillPointsAvailable === 1 ? "point" : "points"}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ function useViewportWidth(): number {
|
||||||
|
|
||||||
const TONES: Record<Toast["tone"], string> = {
|
const TONES: Record<Toast["tone"], string> = {
|
||||||
good: "border-emerald-400/40 text-emerald-50",
|
good: "border-emerald-400/40 text-emerald-50",
|
||||||
info: "border-glass-line text-ink-50",
|
info: "border-cyan-400/40 text-cyan-50",
|
||||||
warn: "border-action/50 text-ink-50",
|
warn: "border-amber-400/50 text-amber-50",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -80,7 +80,7 @@ function ToastItem({
|
||||||
onTouchEnd={() => setPaused(false)}
|
onTouchEnd={() => setPaused(false)}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
className={`pointer-events-auto flex items-start gap-2 rounded-xl border bg-white/5 py-2 pr-1.5 pl-3 text-sm shadow-panel backdrop-blur ${TONES[toast.tone]} ${motion ? (desktop ? "animate-slide" : "animate-rise") : ""}`}
|
className={`pointer-events-auto flex items-start gap-2 rounded-xl border bg-space-900/95 py-2 pr-1.5 pl-3 text-sm shadow-panel backdrop-blur ${TONES[toast.tone]} ${motion ? (desktop ? "animate-slide" : "animate-rise") : ""}`}
|
||||||
>
|
>
|
||||||
<button type="button" onClick={open} className="min-w-0 flex-1 py-0.5 text-left">
|
<button type="button" onClick={open} className="min-w-0 flex-1 py-0.5 text-left">
|
||||||
{toast.text}
|
{toast.text}
|
||||||
|
|
@ -136,7 +136,7 @@ export function Toasts() {
|
||||||
dropOverflow();
|
dropOverflow();
|
||||||
void navigate("/logbook");
|
void navigate("/logbook");
|
||||||
}}
|
}}
|
||||||
className="pointer-events-auto self-end rounded-full border border-glass-line bg-white/5 px-3 py-1 font-display text-xs text-ink-200 shadow-panel"
|
className="pointer-events-auto self-end rounded-full border border-space-600 bg-space-900/95 px-3 py-1 font-display text-xs tracking-wide text-ink-200 shadow-panel"
|
||||||
>
|
>
|
||||||
+{more} more
|
+{more} more
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export function WelcomeCards({
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/25 backdrop-blur-sm sm:items-center sm:p-4">
|
<div className="fixed inset-0 z-40 flex items-end justify-center bg-space-950/80 backdrop-blur-sm sm:items-center sm:p-4">
|
||||||
<div
|
<div
|
||||||
ref={dialog}
|
ref={dialog}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|
@ -85,13 +85,19 @@ export function WelcomeCards({
|
||||||
if (from - to > 50) advance();
|
if (from - to > 50) advance();
|
||||||
else if (to - from > 50 && index > 0) setIndex(index - 1);
|
else if (to - from > 50 && index > 0) setIndex(index - 1);
|
||||||
}}
|
}}
|
||||||
className="w-full max-w-md overflow-hidden rounded-t-2xl border border-glass-line bg-space-850 p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl"
|
className="w-full max-w-md overflow-hidden rounded-t-2xl border border-amber-400/50 bg-space-900/95 p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl"
|
||||||
>
|
>
|
||||||
<p aria-live="polite" className="font-display text-xs font-semibold text-ink-300">
|
<p
|
||||||
|
aria-live="polite"
|
||||||
|
className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase"
|
||||||
|
>
|
||||||
{index + 1} of {cards.length}
|
{index + 1} of {cards.length}
|
||||||
</p>
|
</p>
|
||||||
<div key={index} data-testid="welcome-card" className={reduced ? "" : "animate-slide"}>
|
<div key={index} data-testid="welcome-card" className={reduced ? "" : "animate-slide"}>
|
||||||
<h2 id="welcome-title" className="mt-2 font-title text-lg leading-snug">
|
<h2
|
||||||
|
id="welcome-title"
|
||||||
|
className="mt-2 font-display text-2xl font-bold tracking-wide text-amber-300"
|
||||||
|
>
|
||||||
{card.title}
|
{card.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 text-ink-200">{welcomeBody(card.body)}</p>
|
<p className="mt-3 text-ink-200">{welcomeBody(card.body)}</p>
|
||||||
|
|
@ -100,7 +106,7 @@ export function WelcomeCards({
|
||||||
{cards.map((_, i) => (
|
{cards.map((_, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-1.5 rounded-full ${i === index ? "w-5 bg-action" : "w-1.5 bg-white/25"}`}
|
className={`h-1.5 rounded-full ${i === index ? "w-5 bg-amber-300" : "w-1.5 bg-space-600"}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
MS_PER_HOUR,
|
MS_PER_HOUR,
|
||||||
routeDistance,
|
routeDistance,
|
||||||
travelTimeGameHours,
|
travelTimeGameHours,
|
||||||
pilotShare,
|
|
||||||
tripFuel,
|
tripFuel,
|
||||||
type EffectiveStats,
|
type EffectiveStats,
|
||||||
type ShipState,
|
type ShipState,
|
||||||
|
|
@ -194,27 +193,6 @@ export function readyShips(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* What a job leaves the company on one ship (decision 0.5.169): the fuel at the
|
|
||||||
* tank's average price (0.5.98) and a hired pilot's share (CR-010).
|
|
||||||
*/
|
|
||||||
export function jobMoney(
|
|
||||||
ship: Pick<Ship, "modelId" | "upgrades" | "fuelAvgPrice">,
|
|
||||||
pilot: Pick<Pilot, "isAvatar"> | null | undefined,
|
|
||||||
contract: Pick<Contract, "originId" | "destinationId" | "payout">,
|
|
||||||
) {
|
|
||||||
const fuelFu = tripFuel(
|
|
||||||
shipStatsOf(ship),
|
|
||||||
location(contract.originId),
|
|
||||||
location(contract.destinationId),
|
|
||||||
content.config,
|
|
||||||
);
|
|
||||||
const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice);
|
|
||||||
const pilotPay =
|
|
||||||
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
|
|
||||||
return { fuelFu, fuelCost, pilotPay, netProfit: contract.payout - fuelCost - pilotPay };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a job takes in game hours (the trip's travel and handling), for the
|
* How long a job takes in game hours (the trip's travel and handling), for the
|
||||||
* player's fastest ship that could take it: one at the job's origin if any,
|
* player's fastest ship that could take it: one at the job's origin if any,
|
||||||
|
|
|
||||||
|
|
@ -1,107 +1,36 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@fontsource/michroma/latin-400.css";
|
@import "@fontsource/chakra-petch/latin-500.css";
|
||||||
@import "@fontsource/instrument-sans/latin-400.css";
|
@import "@fontsource/chakra-petch/latin-600.css";
|
||||||
@import "@fontsource/instrument-sans/latin-500.css";
|
@import "@fontsource/chakra-petch/latin-700.css";
|
||||||
@import "@fontsource/instrument-sans/latin-600.css";
|
@import "@fontsource/barlow/latin-400.css";
|
||||||
@import "@fontsource/instrument-sans/latin-700.css";
|
@import "@fontsource/barlow/latin-500.css";
|
||||||
|
@import "@fontsource/barlow/latin-600.css";
|
||||||
|
|
||||||
/* "Deep Field" theme (CR-029, decision 0.5.165). */
|
/* "Freight terminal HUD" theme (CR-005, decisions 0.5.47–0.5.48). */
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Instrument Sans", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif;
|
||||||
/* The wordmark and screen titles only. */
|
--font-display: "Chakra Petch", "Barlow", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-title: "Michroma", "Instrument Sans", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
/* Emphasised labels and numbers, used across the screens. */
|
|
||||||
--font-display: "Instrument Sans", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
|
|
||||||
/* Surfaces, darkest first. */
|
/* Surfaces, darkest first. */
|
||||||
--color-space-950: #050919;
|
--color-space-950: #03050d;
|
||||||
--color-space-900: #0a1228;
|
--color-space-900: #080d1d;
|
||||||
--color-space-850: #0d152e;
|
--color-space-850: #0c132a;
|
||||||
--color-space-800: #121c3a;
|
--color-space-800: #111a36;
|
||||||
--color-space-700: #1c284e;
|
--color-space-700: #1a2548;
|
||||||
--color-space-600: #2a3863;
|
--color-space-600: #26345f;
|
||||||
--color-space-500: #3f4f81;
|
--color-space-500: #3a4a7c;
|
||||||
|
|
||||||
/* Text. Every step stays readable on the glass, whatever is behind it. */
|
/* Text. */
|
||||||
--color-ink-50: #f0f3ff;
|
--color-ink-50: #f2f5ff;
|
||||||
--color-ink-100: #e2e7f7;
|
--color-ink-200: #c9d3ef;
|
||||||
--color-ink-200: #d3daef;
|
--color-ink-300: #a3afd3;
|
||||||
--color-ink-300: #b9c4e0;
|
--color-ink-400: #7e8bb3;
|
||||||
--color-ink-400: #abb6d4;
|
--color-ink-500: #5c688f;
|
||||||
--color-ink-500: #a0accd;
|
|
||||||
|
|
||||||
/* Roles (decision 0.5.165). */
|
--shadow-glow-amber: 0 0 0 1px rgb(251 191 36 / 0.45), 0 0 22px -4px rgb(251 191 36 / 0.6);
|
||||||
--color-glass: rgb(13 21 46 / 0.82);
|
--shadow-glow-cyan: 0 0 0 1px rgb(34 211 238 / 0.4), 0 0 22px -4px rgb(34 211 238 / 0.55);
|
||||||
--color-glass-line: rgb(170 195 255 / 0.18);
|
--shadow-glow-emerald: 0 0 0 1px rgb(52 211 153 / 0.4), 0 0 22px -4px rgb(52 211 153 / 0.55);
|
||||||
--color-action: #ffc04d; /* The main action, and the player's own ship. */
|
--shadow-panel: inset 0 1px 0 0 rgb(255 255 255 / 0.05), 0 12px 32px -12px rgb(0 0 0 / 0.8);
|
||||||
--color-on-action: #241700;
|
|
||||||
--color-flight: #93c9ff; /* Other ships' flights. */
|
|
||||||
--color-earned: #84e3b5; /* Money earned. */
|
|
||||||
--color-problem: #ff8d7a; /* Something needs attention. */
|
|
||||||
--color-on-problem: #2a0a05;
|
|
||||||
|
|
||||||
/* Accent scales in the role hues: amber action, blue flight, mint earned, coral problem. */
|
|
||||||
--color-amber-50: #fff8e8;
|
|
||||||
--color-amber-100: #fff0d1;
|
|
||||||
--color-amber-200: #ffe2a8;
|
|
||||||
--color-amber-300: #ffd27a;
|
|
||||||
--color-amber-400: #ffc04d;
|
|
||||||
--color-amber-500: #f5a623;
|
|
||||||
--color-amber-600: #c97f10;
|
|
||||||
--color-amber-700: #9c620f;
|
|
||||||
--color-amber-800: #7a4d10;
|
|
||||||
--color-amber-900: #5c3b10;
|
|
||||||
--color-amber-950: #2e1c04;
|
|
||||||
--color-cyan-50: #eef6ff;
|
|
||||||
--color-cyan-100: #dcedff;
|
|
||||||
--color-cyan-200: #bddcff;
|
|
||||||
--color-cyan-300: #93c9ff;
|
|
||||||
--color-cyan-400: #68aef5;
|
|
||||||
--color-cyan-500: #4690e0;
|
|
||||||
--color-cyan-600: #2f74c2;
|
|
||||||
--color-cyan-700: #2a5c98;
|
|
||||||
--color-cyan-800: #244a78;
|
|
||||||
--color-cyan-900: #1f3d61;
|
|
||||||
--color-cyan-950: #122540;
|
|
||||||
--color-sky-50: #eef6ff;
|
|
||||||
--color-sky-100: #dcedff;
|
|
||||||
--color-sky-200: #bddcff;
|
|
||||||
--color-sky-300: #93c9ff;
|
|
||||||
--color-sky-400: #68aef5;
|
|
||||||
--color-sky-500: #4690e0;
|
|
||||||
--color-sky-600: #2f74c2;
|
|
||||||
--color-sky-700: #2a5c98;
|
|
||||||
--color-sky-800: #244a78;
|
|
||||||
--color-sky-900: #1f3d61;
|
|
||||||
--color-sky-950: #122540;
|
|
||||||
--color-emerald-50: #eafbf3;
|
|
||||||
--color-emerald-100: #d0f6e4;
|
|
||||||
--color-emerald-200: #aaf0cf;
|
|
||||||
--color-emerald-300: #84e3b5;
|
|
||||||
--color-emerald-400: #5ed09a;
|
|
||||||
--color-emerald-500: #3db680;
|
|
||||||
--color-emerald-600: #2c9367;
|
|
||||||
--color-emerald-700: #267553;
|
|
||||||
--color-emerald-800: #215d44;
|
|
||||||
--color-emerald-900: #1c4c39;
|
|
||||||
--color-emerald-950: #0e2c21;
|
|
||||||
--color-rose-50: #fff2ef;
|
|
||||||
--color-rose-100: #ffe2db;
|
|
||||||
--color-rose-200: #ffc8bb;
|
|
||||||
--color-rose-300: #ffa896;
|
|
||||||
--color-rose-400: #ff8d7a;
|
|
||||||
--color-rose-500: #f56f5a;
|
|
||||||
--color-rose-600: #dc5340;
|
|
||||||
--color-rose-700: #b54232;
|
|
||||||
--color-rose-800: #8f372b;
|
|
||||||
--color-rose-900: #732f26;
|
|
||||||
--color-rose-950: #3f1610;
|
|
||||||
|
|
||||||
/* Selection rings, without the old neon blur. */
|
|
||||||
--shadow-glow-amber: 0 0 0 1px rgb(255 192 77 / 0.55);
|
|
||||||
--shadow-glow-cyan: 0 0 0 1px rgb(147 201 255 / 0.5);
|
|
||||||
--shadow-glow-emerald: 0 0 0 1px rgb(132 227 181 / 0.5);
|
|
||||||
--shadow-panel: 0 10px 30px -14px rgb(0 0 0 / 0.7);
|
|
||||||
|
|
||||||
--animate-rise: rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
--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-glow: glow 2.4s ease-in-out infinite;
|
||||||
|
|
@ -159,8 +88,20 @@
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-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 {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-action);
|
outline: 2px solid var(--color-amber-400);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Link, Outlet, useLocation } from "react-router";
|
import { Link, NavLink, Outlet } from "react-router";
|
||||||
import { GameClock } from "../clock/GameClock";
|
import { GameClock } from "../clock/GameClock";
|
||||||
import { SCREENS, tabOf, type ScreenPath } from "../routes";
|
import { SCREENS, type ScreenPath } from "../routes";
|
||||||
import { Icon } from "../ui";
|
import { Icon } from "../ui";
|
||||||
import { useTrackScreen } from "./lastScreen";
|
import { useTrackScreen } from "./lastScreen";
|
||||||
import { MapBackdrop } from "./MapBackdrop";
|
|
||||||
import { panelIsWide, PanelHost } from "./PanelHost";
|
|
||||||
|
|
||||||
export interface LayoutProps {
|
export interface LayoutProps {
|
||||||
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
||||||
|
|
@ -18,9 +16,9 @@ export interface LayoutProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shell (CR-029): the map behind everything, a floating glass header with
|
* HUD header with credits and game clock, the current screen, and icon
|
||||||
* credits and game clock, the current screen in a panel, and icon navigation:
|
* navigation: a bottom bar on phones, a left rail from `md` (CR-005).
|
||||||
* a bottom bar on phones, a floating rail from `md`.
|
* `--header-h` and `--nav-h` let full-height screens (the map) fit exactly.
|
||||||
*/
|
*/
|
||||||
export function Layout({
|
export function Layout({
|
||||||
credits = "—",
|
credits = "—",
|
||||||
|
|
@ -29,52 +27,55 @@ export function Layout({
|
||||||
attention = {},
|
attention = {},
|
||||||
}: LayoutProps) {
|
}: LayoutProps) {
|
||||||
useTrackScreen();
|
useTrackScreen();
|
||||||
const { pathname } = useLocation();
|
|
||||||
const panelWidth = panelIsWide(pathname) ? "45rem" : "26.25rem";
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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]">
|
||||||
style={{ "--panel-w": panelWidth } as CSSProperties}
|
<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">
|
||||||
className="fixed inset-0 overflow-hidden bg-space-950 text-ink-50 [--nav-h:calc(4rem+env(safe-area-inset-bottom))] md:[--nav-h:0px]"
|
|
||||||
>
|
|
||||||
<MapBackdrop />
|
|
||||||
<header className="absolute inset-x-2 top-[max(0.5rem,env(safe-area-inset-top))] z-10 flex h-12 items-center justify-between gap-2 rounded-2xl border border-glass-line bg-glass px-3 md:top-4 md:right-[calc(var(--panel-w)+2rem)] md:left-24 md:backdrop-blur-xl">
|
|
||||||
{/* The wordmark only fits beside the chips from 640 px; on phones the mark alone. */}
|
{/* The wordmark only fits beside the chips from 640 px; on phones the mark alone. */}
|
||||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||||
<LogoMark />
|
<LogoMark />
|
||||||
<span className="hidden font-title text-sm sm:inline">DEADHEAD</span>
|
<span className="hidden font-display text-lg font-bold tracking-[0.28em] sm:inline">
|
||||||
|
DEADHEAD
|
||||||
|
</span>
|
||||||
|
<span className="hidden font-display text-[10px] tracking-[0.3em] text-cyan-300/70 uppercase lg:inline">
|
||||||
|
Space Freight
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3">
|
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||||
<span className="flex items-center gap-1.5 text-ink-300">
|
<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">
|
||||||
<span aria-label="Game clock" className="text-[13px] tabular-nums">
|
<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}
|
{clock}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span aria-label="Credits" className="text-[15px] font-semibold tabular-nums">
|
|
||||||
{credits}
|
|
||||||
</span>
|
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
className="-mr-1.5 grid size-11 place-items-center rounded-lg text-ink-300 transition hover:bg-white/8 hover:text-ink-50"
|
className="grid size-8 place-items-center rounded-lg border border-space-600 bg-space-900/80 text-ink-300 transition hover:border-amber-400/60 hover:text-amber-200 sm:size-9"
|
||||||
>
|
>
|
||||||
<Icon name="gear" className="size-5" />
|
<Icon name="gear" className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{banner && (
|
{banner}
|
||||||
<div className="absolute inset-x-2 top-16 z-10 overflow-hidden rounded-xl md:top-20 md:right-[calc(var(--panel-w)+2rem)] md:left-24">
|
|
||||||
{banner}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PanelHost>
|
<main className="flex-1 overflow-x-hidden pb-[calc(var(--nav-h)+1.5rem)]">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</PanelHost>
|
</main>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
aria-label="Main"
|
aria-label="Main"
|
||||||
className="absolute inset-x-0 bottom-0 z-10 flex h-(--nav-h) border-t border-glass-line bg-space-950/95 pb-[env(safe-area-inset-bottom)] md:top-4 md:right-auto md:bottom-4 md:left-4 md:h-auto md:w-16 md:flex-col md:gap-1 md:overflow-y-auto md:rounded-3xl md:border md:bg-glass md:py-2 md:backdrop-blur-xl"
|
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-8 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-0.5 md:overflow-y-auto md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4"
|
||||||
>
|
>
|
||||||
|
<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) => (
|
{SCREENS.map((screen) => (
|
||||||
<NavTab
|
<NavTab
|
||||||
key={screen.path}
|
key={screen.path}
|
||||||
|
|
@ -82,7 +83,6 @@ export function Layout({
|
||||||
label={screen.label}
|
label={screen.label}
|
||||||
icon={screen.icon}
|
icon={screen.icon}
|
||||||
badge={attention[screen.path] ?? 0}
|
badge={attention[screen.path] ?? 0}
|
||||||
active={tabOf(pathname) === screen.path}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -95,36 +95,54 @@ function NavTab({
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
badge,
|
badge,
|
||||||
active,
|
|
||||||
}: {
|
}: {
|
||||||
path: ScreenPath;
|
path: ScreenPath;
|
||||||
label: string;
|
label: string;
|
||||||
icon: (typeof SCREENS)[number]["icon"];
|
icon: (typeof SCREENS)[number]["icon"];
|
||||||
badge: number;
|
badge: number;
|
||||||
active: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<NavLink
|
||||||
to={path}
|
to={path}
|
||||||
aria-current={active ? "page" : undefined}
|
end={path === "/"}
|
||||||
className={`relative flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors md:flex-none md:py-2 ${
|
className={({ isActive }) =>
|
||||||
active ? "text-action" : "text-ink-300 hover:text-ink-50"
|
`group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-1.5 ${
|
||||||
}`}
|
isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50"
|
||||||
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="relative grid size-6 place-items-center">
|
{({ isActive }) => (
|
||||||
<Icon name={icon} className="size-5.5" />
|
<>
|
||||||
{badge > 0 && (
|
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-testid={`badge-${label}`}
|
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 ${
|
||||||
className="absolute -top-1.5 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-problem px-1 text-xs leading-none font-bold text-on-problem"
|
isActive ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`relative grid size-10 place-items-center rounded-xl transition md:size-11 ${
|
||||||
|
isActive
|
||||||
|
? "bg-amber-500/15 shadow-glow-amber"
|
||||||
|
: "group-hover:bg-white/5 group-active:scale-95"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{badge > 9 ? "9+" : badge}
|
<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>
|
||||||
)}
|
<span className="font-display text-[10px] font-semibold tracking-wide uppercase sm:text-[11px] sm:tracking-wider">
|
||||||
</span>
|
{label}
|
||||||
{label}
|
</span>
|
||||||
</Link>
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { useLocation, useNavigate } from "react-router";
|
|
||||||
import { resetMapView } from "../map/camera";
|
|
||||||
import { useMapSelection } from "../map/selection";
|
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
|
||||||
import { buttonClass } from "../ui";
|
|
||||||
|
|
||||||
// The map pulls in PixiJS: load it after the first paint.
|
|
||||||
const MapCanvas = lazy(() => import("../map/MapCanvas").then((m) => ({ default: m.MapCanvas })));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one map canvas of the session, behind every screen (decision 0.5.166).
|
|
||||||
* A tap on a place or ship opens it in the map view.
|
|
||||||
*/
|
|
||||||
export function MapBackdrop() {
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const selection = useMapSelection((s) => s.selection);
|
|
||||||
const select = useMapSelection((s) => s.select);
|
|
||||||
const onMap = pathname === "/map";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 z-0">
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<MapCanvas
|
|
||||||
state={state}
|
|
||||||
focused={onMap}
|
|
||||||
selected={selection}
|
|
||||||
onSelect={(next) => {
|
|
||||||
select(next);
|
|
||||||
if (next && !onMap) void navigate("/map");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
{onMap && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetMapView}
|
|
||||||
className={`absolute top-[calc(max(0.5rem,env(safe-area-inset-top))+3.5rem)] right-2 md:top-20 md:right-[calc(var(--panel-w)+2rem)] ${buttonClass("secondary", "sm")} bg-glass`}
|
|
||||||
>
|
|
||||||
Reset view
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
||||||
import { useLocation } from "react-router";
|
|
||||||
import { useMapFrame } from "../map/camera";
|
|
||||||
|
|
||||||
type Detent = "peek" | "half" | "full";
|
|
||||||
|
|
||||||
/** Screens that open tall on phones (decision 0.5.166). */
|
|
||||||
const OPEN_FULL = new Set(["/settings", "/logbook", "/pilots", "/hqs", "/locations", "/market"]);
|
|
||||||
/** Screens with extra columns: a wider panel on desktop. */
|
|
||||||
const WIDE = new Set(["/contracts", "/logbook"]);
|
|
||||||
|
|
||||||
const PEEK_PX = 96;
|
|
||||||
const HALF = 0.56;
|
|
||||||
const HEADER_PX = 64;
|
|
||||||
const NAV_PX = 64;
|
|
||||||
|
|
||||||
const detentPx = (detent: Detent, height: number) =>
|
|
||||||
detent === "peek"
|
|
||||||
? PEEK_PX
|
|
||||||
: detent === "half"
|
|
||||||
? Math.round(height * HALF)
|
|
||||||
: height - HEADER_PX - NAV_PX;
|
|
||||||
|
|
||||||
export const panelIsWide = (pathname: string) => WIDE.has(pathname);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The glass panel screens live in (decision 0.5.166): a bottom sheet with drag
|
|
||||||
* detents on phones, a right-hand column from `md`. On `/map` it holds only the
|
|
||||||
* selected place or ship. It tells the map where the uncovered area is.
|
|
||||||
*/
|
|
||||||
export function PanelHost({ children }: { children: ReactNode }) {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const onMap = pathname === "/map";
|
|
||||||
const [detent, setDetent] = useState<Detent>(OPEN_FULL.has(pathname) ? "full" : "half");
|
|
||||||
const [dragPx, setDragPx] = useState<number | null>(null);
|
|
||||||
const hostRef = useRef<HTMLElement>(null);
|
|
||||||
const drag = useRef<{ startY: number; startPx: number; moved: number } | null>(null);
|
|
||||||
|
|
||||||
// Each screen opens at its own height.
|
|
||||||
useEffect(() => {
|
|
||||||
setDetent(OPEN_FULL.has(pathname) ? "full" : "half");
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
// Keep the map centered in what the panel leaves showing.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const host = hostRef.current;
|
|
||||||
if (!host) return;
|
|
||||||
const measure = () => {
|
|
||||||
const rect = host.getBoundingClientRect();
|
|
||||||
const { innerWidth: width, innerHeight: height } = window;
|
|
||||||
const desktop = width >= 768;
|
|
||||||
const left = desktop ? 96 : 0;
|
|
||||||
const right = desktop ? rect.left : width;
|
|
||||||
const top = desktop ? 0 : HEADER_PX;
|
|
||||||
const bottom = desktop ? height : onMap ? height - NAV_PX : rect.top;
|
|
||||||
useMapFrame.setState({
|
|
||||||
offsetX: (left + right) / 2 - width / 2,
|
|
||||||
offsetY: (top + bottom) / 2 - height / 2,
|
|
||||||
covered: !desktop && bottom - top < 40,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
measure();
|
|
||||||
if (typeof ResizeObserver === "undefined") return;
|
|
||||||
const observer = new ResizeObserver(measure);
|
|
||||||
observer.observe(host);
|
|
||||||
window.addEventListener("resize", measure);
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
window.removeEventListener("resize", measure);
|
|
||||||
};
|
|
||||||
}, [onMap, pathname]);
|
|
||||||
|
|
||||||
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
||||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
||||||
drag.current = {
|
|
||||||
startY: e.clientY,
|
|
||||||
startPx: detentPx(detent, window.innerHeight),
|
|
||||||
moved: 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
||||||
if (!drag.current) return;
|
|
||||||
const delta = drag.current.startY - e.clientY;
|
|
||||||
drag.current.moved = Math.max(drag.current.moved, Math.abs(delta));
|
|
||||||
if (drag.current.moved > 4) {
|
|
||||||
const max = detentPx("full", window.innerHeight);
|
|
||||||
setDragPx(Math.max(PEEK_PX, Math.min(max, drag.current.startPx + delta)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onPointerUp = () => {
|
|
||||||
if (dragPx !== null) {
|
|
||||||
const details: Detent[] = ["peek", "half", "full"];
|
|
||||||
const nearest = details.reduce((best, d) =>
|
|
||||||
Math.abs(detentPx(d, window.innerHeight) - dragPx) <
|
|
||||||
Math.abs(detentPx(best, window.innerHeight) - dragPx)
|
|
||||||
? d
|
|
||||||
: best,
|
|
||||||
);
|
|
||||||
setDetent(nearest);
|
|
||||||
}
|
|
||||||
setDragPx(null);
|
|
||||||
drag.current = null;
|
|
||||||
};
|
|
||||||
// A tap or the keyboard steps through the heights, so dragging is never required.
|
|
||||||
const step = () => {
|
|
||||||
if (drag.current && drag.current.moved > 4) return;
|
|
||||||
setDetent((d) => (d === "half" ? "full" : d === "full" ? "peek" : "half"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const wide = WIDE.has(pathname);
|
|
||||||
if (onMap) {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
ref={hostRef}
|
|
||||||
className="pointer-events-none absolute inset-x-2 bottom-[calc(var(--nav-h)+0.5rem)] z-10 md:top-4 md:right-4 md:bottom-auto md:left-auto md:w-(--panel-w)"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
ref={hostRef}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sheet-h": `${dragPx ?? detentPx(detent, globalThis.innerHeight ?? 800)}px`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
className={`absolute inset-x-0 bottom-(--nav-h) z-10 flex h-(--sheet-h) flex-col rounded-t-3xl border-t border-glass-line bg-space-850/97 shadow-panel md:bg-glass md:top-4 md:right-4 md:bottom-auto md:left-auto md:h-auto md:max-h-[calc(100%-2rem)] md:w-(--panel-w) md:rounded-3xl md:border md:backdrop-blur-xl ${
|
|
||||||
dragPx === null ? "transition-[height] duration-300 ease-out" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Panel height: ${detent}. Change panel height`}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerCancel={onPointerUp}
|
|
||||||
onClick={step}
|
|
||||||
className="-mb-4 flex h-11 shrink-0 touch-none items-start justify-center pt-2.5 md:hidden"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="h-1 w-9 rounded-full bg-white/30" />
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={`@container min-h-0 flex-1 overflow-y-auto overscroll-contain ${wide ? "" : ""}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import type { PlayerState } from "@deadhead/schema";
|
import type { PlayerState } from "@deadhead/schema";
|
||||||
import { needsYou } from "../components/NeedsYou";
|
import { readyShips } from "../game";
|
||||||
import type { ScreenPath } from "../routes";
|
import type { ScreenPath } from "../routes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counts for the navigation's badges: what the Bridge's Needs you list holds,
|
* Counts for the navigation's attention badges (decision 0.5.49): goals ready
|
||||||
* per tab (decisions 0.5.49, 0.5.168). Decorative; the list says what they are.
|
* to claim and radio calls waiting (decision 0.5.144), ships ready for a job (decision 0.5.134), and unspent skill points
|
||||||
|
* across all pilots (decision 0.5.109).
|
||||||
*/
|
*/
|
||||||
export function tabAttention(state: PlayerState): Partial<Record<ScreenPath, number>> {
|
export function tabAttention(state: PlayerState): Partial<Record<ScreenPath, number>> {
|
||||||
const counts: Partial<Record<ScreenPath, number>> = { "/": 0, "/contracts": 0, "/fleet": 0 };
|
const ready = state.tutorial ? (state.tutorial.runInFlight ? 0 : 1) : readyShips(state).length;
|
||||||
for (const item of needsYou(state)) counts[item.tab] = (counts[item.tab] ?? 0) + item.count;
|
return {
|
||||||
return counts;
|
// Goals to claim and radio calls to answer (CR-026).
|
||||||
|
"/": state.goals.filter((g) => g.status === "ready").length + state.radioCalls.length,
|
||||||
|
"/contracts": ready,
|
||||||
|
"/pilots": state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,13 @@ import { Application, Assets, type Texture } from "pixi.js";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { serverNow } from "../clock/clock";
|
import { serverNow } from "../clock/clock";
|
||||||
import { loadArt } from "./art/load";
|
import { loadArt } from "./art/load";
|
||||||
import { framed, pan, useCamera, useMapFrame, zoomAt, type Viewport } from "./camera";
|
import { pan, useCamera, zoomAt, type Viewport } from "./camera";
|
||||||
import { hitTest, type MapSelection } from "./hitTest";
|
import { hitTest, type MapSelection } from "./hitTest";
|
||||||
import { MapRenderer } from "./renderer";
|
import { MapRenderer } from "./renderer";
|
||||||
import { buildScene } from "./scene";
|
import { buildScene } from "./scene";
|
||||||
import { mapStyle } from "./style";
|
import { mapStyle } from "./style";
|
||||||
import { runWhileVisible } from "./ticker";
|
import { runWhileVisible } from "./ticker";
|
||||||
|
|
||||||
/** Frame rate while a panel is in front (decision 0.5.166). */
|
|
||||||
const BEHIND_PANEL_FPS = 30;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The system map canvas (Steps 10.1–10.2, CR-003). Loads the map art, then
|
* The system map canvas (Steps 10.1–10.2, CR-003). Loads the map art, then
|
||||||
* draws locations and ships every frame from `state` and the server clock;
|
* draws locations and ships every frame from `state` and the server clock;
|
||||||
|
|
@ -22,15 +19,12 @@ export function MapCanvas({
|
||||||
state,
|
state,
|
||||||
onSelect,
|
onSelect,
|
||||||
selected = null,
|
selected = null,
|
||||||
focused = true,
|
|
||||||
}: {
|
}: {
|
||||||
state: PlayerState;
|
state: PlayerState;
|
||||||
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
|
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
|
||||||
onSelect?: (selection: MapSelection | null) => void;
|
onSelect?: (selection: MapSelection | null) => void;
|
||||||
/** Highlighted with a ring (Step 10C.8). */
|
/** Highlighted with a ring (Step 10C.8). */
|
||||||
selected?: MapSelection | null;
|
selected?: MapSelection | null;
|
||||||
/** The map is what the player is looking at: full frame rate and a loading note. */
|
|
||||||
focused?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(state);
|
||||||
|
|
@ -40,21 +34,12 @@ export function MapCanvas({
|
||||||
const selectedRef = useRef(selected);
|
const selectedRef = useRef(selected);
|
||||||
selectedRef.current = selected;
|
selectedRef.current = selected;
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const tickerRef = useRef<{ maxFPS: number } | null>(null);
|
|
||||||
const frameRate = focused ? 0 : BEHIND_PANEL_FPS;
|
|
||||||
const frameRateRef = useRef(frameRate);
|
|
||||||
frameRateRef.current = frameRate;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tickerRef.current) tickerRef.current.maxFPS = frameRate;
|
|
||||||
}, [frameRate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let stopTicker = () => {};
|
let stopTicker = () => {};
|
||||||
let stopFrameWatch = () => {};
|
|
||||||
let renderer: MapRenderer | null = null;
|
let renderer: MapRenderer | null = null;
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
|
|
||||||
|
|
@ -63,7 +48,6 @@ export function MapCanvas({
|
||||||
height: container.clientHeight,
|
height: container.clientHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
const camera = () => framed(useCamera.getState(), useMapFrame.getState());
|
|
||||||
// Art and renderer load together; missing art falls back to placeholders (0.5.41).
|
// Art and renderer load together; missing art falls back to placeholders (0.5.41).
|
||||||
const art = loadArt<Texture>(Assets, window.devicePixelRatio);
|
const art = loadArt<Texture>(Assets, window.devicePixelRatio);
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
|
|
@ -90,29 +74,12 @@ export function MapCanvas({
|
||||||
map.draw(
|
map.draw(
|
||||||
buildScene(stateRef.current, serverNow()),
|
buildScene(stateRef.current, serverNow()),
|
||||||
viewport(),
|
viewport(),
|
||||||
camera(),
|
useCamera.getState(),
|
||||||
selectedRef.current,
|
selectedRef.current,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
tickerRef.current = app.ticker;
|
// Only draw while the page is visible (Step 10C.9).
|
||||||
app.ticker.maxFPS = frameRateRef.current;
|
stopTicker = runWhileVisible(app.ticker);
|
||||||
// Only draw while the page is visible (Step 10C.9) and not fully covered (0.5.166).
|
|
||||||
let wanted = false;
|
|
||||||
const apply = () => {
|
|
||||||
if (wanted && !useMapFrame.getState().covered) app.ticker.start();
|
|
||||||
else app.ticker.stop();
|
|
||||||
};
|
|
||||||
stopFrameWatch = useMapFrame.subscribe(apply);
|
|
||||||
stopTicker = runWhileVisible({
|
|
||||||
start: () => {
|
|
||||||
wanted = true;
|
|
||||||
apply();
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
wanted = false;
|
|
||||||
apply();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
|
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
|
||||||
|
|
@ -123,13 +90,6 @@ export function MapCanvas({
|
||||||
|
|
||||||
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
|
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
|
||||||
// pointer that barely moves is a tap, which selects what is under it.
|
// pointer that barely moves is a tap, which selects what is under it.
|
||||||
const zoom = (factor: number, anchor: { x: number; y: number }) => {
|
|
||||||
const frame = useMapFrame.getState();
|
|
||||||
useCamera.setState((c) => {
|
|
||||||
const next = zoomAt(framed(c, frame), factor, anchor, viewport());
|
|
||||||
return { ...next, panX: next.panX - frame.offsetX, panY: next.panY - frame.offsetY };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const pointers = new Map<number, { x: number; y: number }>();
|
const pointers = new Map<number, { x: number; y: number }>();
|
||||||
let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null;
|
let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null;
|
||||||
const local = (e: PointerEvent | WheelEvent) => {
|
const local = (e: PointerEvent | WheelEvent) => {
|
||||||
|
|
@ -158,7 +118,7 @@ export function MapCanvas({
|
||||||
const before = Math.hypot(previous.x - other.x, previous.y - other.y);
|
const before = Math.hypot(previous.x - other.x, previous.y - other.y);
|
||||||
const after = Math.hypot(current.x - other.x, current.y - other.y);
|
const after = Math.hypot(current.x - other.x, current.y - other.y);
|
||||||
const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 };
|
const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 };
|
||||||
if (before > 0) zoom(after / before, mid);
|
if (before > 0) useCamera.setState((c) => zoomAt(c, after / before, mid, viewport()));
|
||||||
}
|
}
|
||||||
pointers.set(e.pointerId, current);
|
pointers.set(e.pointerId, current);
|
||||||
};
|
};
|
||||||
|
|
@ -171,13 +131,13 @@ export function MapCanvas({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const scene = buildScene(stateRef.current, serverNow());
|
const scene = buildScene(stateRef.current, serverNow());
|
||||||
onSelectRef.current?.(hitTest(scene, local(e), viewport(), camera()));
|
onSelectRef.current?.(hitTest(scene, local(e), viewport(), useCamera.getState()));
|
||||||
}
|
}
|
||||||
if (tap?.id === e.pointerId) tap = null;
|
if (tap?.id === e.pointerId) tap = null;
|
||||||
};
|
};
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
zoom(Math.exp(-e.deltaY * 0.001), local(e));
|
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport()));
|
||||||
};
|
};
|
||||||
container.addEventListener("pointerdown", onDown);
|
container.addEventListener("pointerdown", onDown);
|
||||||
container.addEventListener("pointermove", onMove);
|
container.addEventListener("pointermove", onMove);
|
||||||
|
|
@ -188,8 +148,6 @@ export function MapCanvas({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
stopTicker();
|
stopTicker();
|
||||||
stopFrameWatch();
|
|
||||||
tickerRef.current = null;
|
|
||||||
container.removeEventListener("pointerdown", onDown);
|
container.removeEventListener("pointerdown", onDown);
|
||||||
container.removeEventListener("pointermove", onMove);
|
container.removeEventListener("pointermove", onMove);
|
||||||
container.removeEventListener("pointerup", onUp);
|
container.removeEventListener("pointerup", onUp);
|
||||||
|
|
@ -205,10 +163,10 @@ export function MapCanvas({
|
||||||
data-testid="map-canvas"
|
data-testid="map-canvas"
|
||||||
className="relative h-full w-full touch-none overflow-hidden"
|
className="relative h-full w-full touch-none overflow-hidden"
|
||||||
>
|
>
|
||||||
{loading && focused && (
|
{loading && (
|
||||||
<p
|
<p
|
||||||
role="status"
|
role="status"
|
||||||
className="absolute inset-x-0 top-4 text-center font-display text-sm text-ink-400"
|
className="absolute inset-x-0 top-4 text-center font-display text-sm tracking-widest text-ink-400 uppercase"
|
||||||
>
|
>
|
||||||
Loading map…
|
Loading map…
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -44,21 +44,6 @@ export function resetMapView(): void {
|
||||||
usePreferences.getState().set({ mapCamera: null });
|
usePreferences.getState().set({ mapCamera: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the map's center belongs: panels cover part of the screen, so the view
|
|
||||||
* centers on what's left (decision 0.5.166). `covered`: nothing is showing.
|
|
||||||
*/
|
|
||||||
export const useMapFrame = create<{ offsetX: number; offsetY: number; covered: boolean }>(() => ({
|
|
||||||
offsetX: 0,
|
|
||||||
offsetY: 0,
|
|
||||||
covered: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** The camera shifted by the frame, as drawn and hit-tested. */
|
|
||||||
export function framed(camera: Camera, frame: { offsetX: number; offsetY: number }): Camera {
|
|
||||||
return { ...camera, panX: camera.panX + frame.offsetX, panY: camera.panY + frame.offsetY };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pixels per map unit at this zoom. */
|
/** Pixels per map unit at this zoom. */
|
||||||
export function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
|
export function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
|
||||||
return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom;
|
return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { create } from "zustand";
|
|
||||||
import type { MapSelection } from "./hitTest";
|
|
||||||
|
|
||||||
/** What the player last tapped on the map, shown in the map view (decision 0.5.166). */
|
|
||||||
export const useMapSelection = create<{
|
|
||||||
selection: MapSelection | null;
|
|
||||||
select: (selection: MapSelection | null) => void;
|
|
||||||
}>((set) => ({
|
|
||||||
selection: null,
|
|
||||||
select: (selection) => set({ selection }),
|
|
||||||
}));
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
import type { IconName } from "./ui/icons";
|
import type { IconName } from "./ui/icons";
|
||||||
|
|
||||||
/** The main tabs, in navigation order (decision 0.5.167). */
|
/** The app's top-level screens, in navigation order. Labels are short to fit eight tabs. */
|
||||||
export const SCREENS = [
|
export const SCREENS = [
|
||||||
{ path: "/", title: "Bridge", label: "Bridge", icon: "helm" },
|
// The Dashboard, "Bridge" in the navigation (CR-020, decision 0.5.118).
|
||||||
{ path: "/contracts", title: "Jobs", label: "Jobs", icon: "jobs" },
|
{ path: "/", title: "Dashboard", label: "Bridge", icon: "helm" },
|
||||||
|
{ 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: "/fleet", title: "Fleet", label: "Fleet", icon: "fleet" },
|
||||||
{ path: "/more", title: "More", label: "More", icon: "more" },
|
{ path: "/pilots", title: "Pilots", label: "Pilots", icon: "pilots" },
|
||||||
|
{ path: "/hqs", title: "HQs", label: "HQs", icon: "hq" },
|
||||||
|
{ 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 }[];
|
] as const satisfies readonly { path: string; title: string; label: string; icon: IconName }[];
|
||||||
|
|
||||||
export type ScreenPath = (typeof SCREENS)[number]["path"];
|
export type ScreenPath = (typeof SCREENS)[number]["path"];
|
||||||
|
|
||||||
/** The tab a screen belongs to: Crew sits under Fleet, the map under the Bridge. */
|
|
||||||
export function tabOf(pathname: string): ScreenPath {
|
|
||||||
if (pathname === "/" || pathname === "/map") return "/";
|
|
||||||
if (pathname === "/contracts") return "/contracts";
|
|
||||||
if (pathname === "/fleet" || pathname === "/pilots") return "/fleet";
|
|
||||||
return "/more";
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { usePreferences } from "../state/preferences";
|
||||||
import { Button, Sheet } from "../ui";
|
import { Button, Sheet } from "../ui";
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"min-h-11 w-full rounded-lg border border-glass-line bg-black/25 px-3 py-2 text-sm text-ink-50 focus:border-action focus:outline-none";
|
"w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none";
|
||||||
|
|
||||||
/** The company name, editable in place with the server's own rules (decision 0.5.75). */
|
/** The company name, editable in place with the server's own rules (decision 0.5.75). */
|
||||||
export function CompanyName() {
|
export function CompanyName() {
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,14 @@ export function Contracts() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const [selected, setSelected] = useState<Contract | null>(null);
|
const [selected, setSelected] = useState<Contract | null>(null);
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-4xl">
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title="Jobs"
|
title="Contracts"
|
||||||
eyebrow={state.tutorial ? content.tutorialCompanyName : undefined}
|
eyebrow={state.tutorial ? content.tutorialCompanyName : "Freight market"}
|
||||||
icon="jobs"
|
icon="jobs"
|
||||||
/>
|
/>
|
||||||
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
||||||
{selected && (
|
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
|
||||||
<DispatchSheet key={selected.id} contract={selected} onClose={() => setSelected(null)} />
|
|
||||||
)}
|
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { FleetAsleep } from "../components/FleetSleep";
|
import { FleetAsleep } from "../components/FleetSleep";
|
||||||
import { NeedsYou } from "../components/NeedsYou";
|
import { RadioCallsPanel } from "../components/RadioCalls";
|
||||||
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
||||||
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
||||||
import { dispatchProgress } from "@deadhead/sim";
|
import { dispatchProgress } from "@deadhead/sim";
|
||||||
|
|
@ -16,7 +16,18 @@ import { formatGameHours, locationName, shipModelName } from "../format";
|
||||||
import { lowFuel } from "../game";
|
import { lowFuel } from "../game";
|
||||||
import { ShopRest } from "../components/ShopRest";
|
import { ShopRest } from "../components/ShopRest";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
import { Art, buttonClass, Eyebrow, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
|
import {
|
||||||
|
Art,
|
||||||
|
buttonClass,
|
||||||
|
Eyebrow,
|
||||||
|
Icon,
|
||||||
|
Meter,
|
||||||
|
Panel,
|
||||||
|
Pill,
|
||||||
|
Screen,
|
||||||
|
ScreenHeader,
|
||||||
|
StatTile,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
/** What the player needs to do next to reach the deal (decision 0.5.30). */
|
/** What the player needs to do next to reach the deal (decision 0.5.30). */
|
||||||
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
|
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
|
||||||
|
|
@ -82,122 +93,159 @@ export function Dashboard() {
|
||||||
.sort((a, b) => a.inShopUntil! - b.inShopUntil!);
|
.sort((a, b) => a.inShopUntil! - b.inShopUntil!);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-5xl">
|
||||||
<ScreenHeader title="Bridge" eyebrow={state.company.name} icon="helm" />
|
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
|
||||||
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
|
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
|
||||||
<FleetAsleep />
|
<FleetAsleep />
|
||||||
|
{/* Calls waiting for an answer, each reopening its card (CR-026). */}
|
||||||
|
<RadioCallsPanel />
|
||||||
|
|
||||||
{state.tutorial && (
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Panel tone="amber">
|
<YourShip className="md:col-span-2" />
|
||||||
<Eyebrow>Contract pilot</Eyebrow>
|
<Alerts />
|
||||||
<h2 className="mt-0.5 text-lg font-semibold">Flying for {content.tutorialCompanyName}</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-4 @2xl:grid-cols-2">
|
|
||||||
<YourShip />
|
|
||||||
<NeedsYou />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section aria-labelledby="on-jobs-title">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex items-baseline justify-between">
|
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
|
||||||
<h2 id="on-jobs-title" className="text-sm font-semibold">
|
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
|
||||||
In flight
|
</div>
|
||||||
</h2>
|
|
||||||
<span aria-label="Ships in flight" className="text-xs text-ink-300">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{flying} flying · {idle} idle
|
<div className="space-y-4">
|
||||||
</span>
|
{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 {content.tutorialCompanyName}
|
||||||
|
</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
|
||||||
|
as="section"
|
||||||
|
aria-labelledby="on-jobs-title"
|
||||||
|
tone={onJobs.length ? "cyan" : "default"}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="on-jobs-title"
|
||||||
|
className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase"
|
||||||
|
>
|
||||||
|
Ships on jobs
|
||||||
|
</h2>
|
||||||
|
{onJobs.length === 0 ? (
|
||||||
|
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-2 space-y-3">
|
||||||
|
{onJobs.map((d) => {
|
||||||
|
const name = flightShipName(state, d);
|
||||||
|
return (
|
||||||
|
<li key={d.id} aria-label={name} className="text-sm">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="font-semibold text-ink-50">{name}</span>{" "}
|
||||||
|
<span className="text-ink-300">
|
||||||
|
{locationName(d.originId)} → {locationName(d.destinationId)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-display font-bold whitespace-nowrap text-cyan-200">
|
||||||
|
<Countdown to={d.arrivalAt} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Meter
|
||||||
|
label={`${name} trip`}
|
||||||
|
value={Math.round(dispatchProgress(d, now) * 100)}
|
||||||
|
tone="cyan"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inShop.length > 0 && (
|
||||||
|
<Panel aria-label="In the shop" tone="amber">
|
||||||
|
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
|
||||||
|
In the shop: {inShop.length}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
|
||||||
|
{inShop.map((ship) => (
|
||||||
|
<li key={ship.id}>
|
||||||
|
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
|
||||||
|
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
{onJobs.length === 0 ? (
|
|
||||||
<p className="py-2 text-sm text-ink-300">Nothing in flight.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-glass-line">
|
|
||||||
{onJobs.map((d) => {
|
|
||||||
const name = flightShipName(state, d);
|
|
||||||
return (
|
|
||||||
<li key={d.id} aria-label={name} className="py-2.5 text-sm">
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
<span className="font-medium text-ink-50">{name}</span>{" "}
|
|
||||||
<span className="text-ink-300">
|
|
||||||
{locationName(d.originId)} → {locationName(d.destinationId)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold whitespace-nowrap tabular-nums">
|
|
||||||
<Countdown to={d.arrivalAt} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Meter
|
|
||||||
label={`${name} trip`}
|
|
||||||
value={Math.round(dispatchProgress(d, now) * 100)}
|
|
||||||
tone="cyan"
|
|
||||||
size="sm"
|
|
||||||
className="mt-1.5"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{!state.tutorial && resting.length > 0 && (
|
<div className="space-y-4">
|
||||||
<section aria-label="Pilot rest" className="flex items-center gap-3 text-sm">
|
<DispatchOfficePanel />
|
||||||
<Icon name="rest" className="size-5 shrink-0 text-flight" />
|
{!state.tutorial && (
|
||||||
<p className="min-w-0">
|
<Panel className="text-sm" aria-label="Logbook">
|
||||||
<span className="font-medium">Pilots resting: {resting.length}</span>
|
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-ink-100">
|
||||||
<span className="block text-ink-300">
|
<Icon name="route" className="size-4 text-cyan-300" />
|
||||||
{resting[0]!.name} wakes in <Countdown to={resting[0]!.restEndsAt!} done="now" />
|
Logbook
|
||||||
</span>
|
</p>
|
||||||
</p>
|
<p className="mt-1 text-ink-300">
|
||||||
</section>
|
Every job, refuel, rest and upgrade from the last 7 days, pilot by pilot.
|
||||||
)}
|
</p>
|
||||||
|
<Link
|
||||||
{inShop.length > 0 && (
|
to="/logbook"
|
||||||
<section aria-label="In the shop" className="text-sm">
|
className="mt-2 inline-block font-semibold text-amber-300 underline"
|
||||||
<h2 className="font-semibold">In the shop: {inShop.length}</h2>
|
>
|
||||||
<ul className="mt-1 space-y-0.5 text-ink-300">
|
Open the Logbook
|
||||||
{inShop.map((ship) => (
|
</Link>
|
||||||
<li key={ship.id}>
|
</Panel>
|
||||||
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name} {ship.shop?.level}{" "}
|
)}
|
||||||
· ready in <Countdown to={ship.inShopUntil!} done="now" />
|
{!state.tutorial && <GoalsCard />}
|
||||||
</li>
|
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||||
))}
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!state.tutorial && state.ships.length === 0 && (
|
|
||||||
<p className="text-sm text-ink-300">No ships yet — buy one in the Shipyard.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!state.tutorial && <GoalsCard />}
|
|
||||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
|
||||||
<DispatchOfficePanel />
|
|
||||||
{!state.tutorial && (
|
|
||||||
<Link
|
|
||||||
to="/logbook"
|
|
||||||
aria-label="Open the Logbook"
|
|
||||||
className="flex min-h-11 items-center gap-2 text-sm font-medium text-ink-200"
|
|
||||||
>
|
|
||||||
<Icon name="route" className="size-4 text-ink-300" />
|
|
||||||
Open the Logbook
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -210,12 +258,12 @@ function YourShip({ className = "" }: { className?: string }) {
|
||||||
|
|
||||||
if (mine.kind === "none") {
|
if (mine.kind === "none") {
|
||||||
return (
|
return (
|
||||||
<Panel as="section" aria-label="Your ship" className={className}>
|
<Panel as="section" aria-label="Your ship" feature className={className}>
|
||||||
<Eyebrow>Your ship</Eyebrow>
|
<Eyebrow>Your ship</Eyebrow>
|
||||||
<p className="mt-2 text-sm text-ink-200">You're not flying a ship right now.</p>
|
<p className="mt-2 text-sm text-ink-200">You're not flying a ship right now.</p>
|
||||||
<Link to="/pilots" className={`mt-3 ${buttonClass("secondary", "sm")}`}>
|
<Link to="/pilots" className={`mt-3 ${buttonClass("secondary", "sm")}`}>
|
||||||
<Icon name="pilots" className="size-4" />
|
<Icon name="pilots" className="size-4" />
|
||||||
Assign yourself in Crew
|
Assign yourself on Pilots
|
||||||
</Link>
|
</Link>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
@ -237,12 +285,13 @@ function YourShip({ className = "" }: { className?: string }) {
|
||||||
<Panel
|
<Panel
|
||||||
as="section"
|
as="section"
|
||||||
aria-label="Your ship"
|
aria-label="Your ship"
|
||||||
tone={dispatch || inShop ? "default" : "amber"}
|
feature
|
||||||
className={`flex flex-col gap-3 self-start ${className}`}
|
tone={dispatch ? "cyan" : "default"}
|
||||||
|
className={`flex flex-col gap-3 ${className}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{sprite && (
|
{sprite && (
|
||||||
<div className="grid size-20 shrink-0 place-items-center">
|
<div className="grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
|
||||||
<Art
|
<Art
|
||||||
sprite={sprite}
|
sprite={sprite}
|
||||||
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
||||||
|
|
@ -252,7 +301,7 @@ function YourShip({ className = "" }: { className?: string }) {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Eyebrow>Your ship</Eyebrow>
|
<Eyebrow>Your ship</Eyebrow>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h2 className="truncate text-lg font-semibold">{name}</h2>
|
<h2 className="truncate font-display text-xl font-bold tracking-wide">{name}</h2>
|
||||||
{dispatch ? (
|
{dispatch ? (
|
||||||
<Pill tone="cyan" icon="send">
|
<Pill tone="cyan" icon="send">
|
||||||
In flight
|
In flight
|
||||||
|
|
@ -286,7 +335,7 @@ function YourShip({ className = "" }: { className?: string }) {
|
||||||
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
|
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
|
||||||
: "Flying empty"}
|
: "Flying empty"}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold">
|
<span className="font-display text-lg font-bold text-cyan-200">
|
||||||
Arrives in <Countdown to={dispatch.arrivalAt} />
|
Arrives in <Countdown to={dispatch.arrivalAt} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -299,12 +348,12 @@ function YourShip({ className = "" }: { className?: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inShop && (
|
{inShop && (
|
||||||
<p className="font-display text-sm text-ink-100">
|
<p className="font-display text-sm text-amber-200">
|
||||||
In the shop — ready in <Countdown to={ship!.inShopUntil!} done="now" />
|
In the shop — ready in <Countdown to={ship!.inShopUntil!} done="now" />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-3 @2xl:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Eyebrow>Fuel</Eyebrow>
|
<Eyebrow>Fuel</Eyebrow>
|
||||||
{ship ? (
|
{ship ? (
|
||||||
|
|
@ -316,8 +365,10 @@ function YourShip({ className = "" }: { className?: string }) {
|
||||||
aria-label="Your ship low fuel"
|
aria-label="Your ship low fuel"
|
||||||
className="mt-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
className="mt-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
||||||
>
|
>
|
||||||
<span className="font-display font-semibold">Low fuel</span> ·{" "}
|
<span className="font-display font-semibold tracking-wide uppercase">
|
||||||
{locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
|
Low fuel
|
||||||
|
</span>{" "}
|
||||||
|
· {locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -355,12 +406,12 @@ function PilotDuty({ pilot }: { pilot: Pilot }) {
|
||||||
{pilot.name} · Level {pilot.level}
|
{pilot.name} · Level {pilot.level}
|
||||||
</Eyebrow>
|
</Eyebrow>
|
||||||
{pilot.restingInShopUntil !== null ? (
|
{pilot.restingInShopUntil !== null ? (
|
||||||
<p className="mt-1 flex items-center gap-1.5 text-sm text-ink-100">
|
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
|
||||||
<Icon name="rest" className="size-4 shrink-0" />
|
<Icon name="rest" className="size-4 shrink-0" />
|
||||||
<ShopRest until={pilot.restingInShopUntil} />
|
<ShopRest until={pilot.restingInShopUntil} />
|
||||||
</p>
|
</p>
|
||||||
) : pilot.resting && pilot.restEndsAt !== null ? (
|
) : pilot.resting && pilot.restEndsAt !== null ? (
|
||||||
<p className="mt-1 flex items-center gap-1.5 text-sm text-ink-100">
|
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
|
||||||
<Icon name="rest" className="size-4 shrink-0" />
|
<Icon name="rest" className="size-4 shrink-0" />
|
||||||
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
|
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -370,7 +421,7 @@ function PilotDuty({ pilot }: { pilot: Pilot }) {
|
||||||
label="Your duty"
|
label="Your duty"
|
||||||
value={Math.min(pilot.dutyHours, pilot.dutyLimit)}
|
value={Math.min(pilot.dutyHours, pilot.dutyLimit)}
|
||||||
max={pilot.dutyLimit}
|
max={pilot.dutyLimit}
|
||||||
tone={pilot.dutyHours / pilot.dutyLimit >= 0.85 ? "rose" : "default"}
|
tone={pilot.dutyHours / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
|
|
@ -383,6 +434,23 @@ function PilotDuty({ pilot }: { pilot: Pilot }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where in-flight alerts and news will appear; empty for now (decision 0.5.120). */
|
||||||
|
function Alerts() {
|
||||||
|
return (
|
||||||
|
<Panel as="section" aria-labelledby="alerts-title" tone="muted">
|
||||||
|
<h2
|
||||||
|
id="alerts-title"
|
||||||
|
className="flex items-center gap-2 font-display text-sm font-semibold tracking-wide text-ink-50"
|
||||||
|
>
|
||||||
|
<Icon name="alert" className="size-4 text-amber-300" />
|
||||||
|
Alerts
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-ink-300">No alerts.</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-500">In-flight alerts and news will appear here.</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** One pip per company run toward the deal. */
|
/** One pip per company run toward the deal. */
|
||||||
function RunPips({ done, total }: { done: number; total: number }) {
|
function RunPips({ done, total }: { done: number; total: number }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -390,7 +458,7 @@ function RunPips({ done, total }: { done: number; total: number }) {
|
||||||
{Array.from({ length: total }, (_, i) => (
|
{Array.from({ length: total }, (_, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-2 flex-1 rounded-full ${i < done ? "bg-action" : "bg-white/14"}`}
|
className={`h-2 flex-1 rounded-full ${i < done ? "bg-amber-400 shadow-glow-amber" : "bg-space-700"}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Button } from "../ui";
|
||||||
|
|
||||||
const MINUTE = 60_000;
|
const MINUTE = 60_000;
|
||||||
const selectClass =
|
const selectClass =
|
||||||
"min-w-0 flex-1 rounded-lg border border-glass-line bg-space-900 px-2 py-1.5 text-sm text-ink-100";
|
"min-w-0 flex-1 rounded-lg border border-space-600 bg-space-900 px-2 py-1.5 text-sm text-ink-100";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings → Developer (non-production builds only, CR-011): fast-forward the
|
* Settings → Developer (non-production builds only, CR-011): fast-forward the
|
||||||
|
|
@ -113,7 +113,7 @@ export function DevTools() {
|
||||||
|
|
||||||
{state.pilots.length > 0 && (
|
{state.pilots.length > 0 && (
|
||||||
<fieldset className="space-y-2">
|
<fieldset className="space-y-2">
|
||||||
<legend className="text-xs text-ink-400">Pilot</legend>
|
<legend className="text-xs tracking-wider text-ink-400 uppercase">Pilot</legend>
|
||||||
<select
|
<select
|
||||||
aria-label="Pilot"
|
aria-label="Pilot"
|
||||||
value={pilotId}
|
value={pilotId}
|
||||||
|
|
@ -149,7 +149,7 @@ export function DevTools() {
|
||||||
|
|
||||||
{state.ships.length > 0 && (
|
{state.ships.length > 0 && (
|
||||||
<fieldset className="space-y-2">
|
<fieldset className="space-y-2">
|
||||||
<legend className="text-xs text-ink-400">Ship</legend>
|
<legend className="text-xs tracking-wider text-ink-400 uppercase">Ship</legend>
|
||||||
<select
|
<select
|
||||||
aria-label="Ship"
|
aria-label="Ship"
|
||||||
value={shipId}
|
value={shipId}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ import {
|
||||||
contractTripGameHours,
|
contractTripGameHours,
|
||||||
isInShop,
|
isInShop,
|
||||||
rangeFrom,
|
rangeFrom,
|
||||||
|
pilotShare,
|
||||||
dutyAfterTrip,
|
dutyAfterTrip,
|
||||||
LONG_HAUL_SKILL_ID,
|
LONG_HAUL_SKILL_ID,
|
||||||
needsRest,
|
needsRest,
|
||||||
realToGame,
|
realToGame,
|
||||||
|
tripFuel,
|
||||||
tripGameHours,
|
tripGameHours,
|
||||||
validateDispatch,
|
validateDispatch,
|
||||||
wouldStrand,
|
wouldStrand,
|
||||||
|
|
@ -29,8 +31,9 @@ import {
|
||||||
skillName,
|
skillName,
|
||||||
} from "../format";
|
} from "../format";
|
||||||
import { requirementsText } from "./LocationBoards";
|
import { requirementsText } from "./LocationBoards";
|
||||||
import { jobMoney, location, tripRealMs, shipStatsOf, simShip } from "../game";
|
import { location, tripRealMs, shipStatsOf, simShip } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
import { Art, Button, Sheet } from "../ui";
|
import { Art, Button, Sheet } from "../ui";
|
||||||
import { goodName, PriorityChip } from "../components/Goods";
|
import { goodName, PriorityChip } from "../components/Goods";
|
||||||
import {
|
import {
|
||||||
|
|
@ -218,7 +221,17 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
contract.destinationId,
|
contract.destinationId,
|
||||||
contract.massTonnes,
|
contract.massTonnes,
|
||||||
);
|
);
|
||||||
const { fuelFu, fuelCost, pilotPay, netProfit } = jobMoney(ship, pilot, contract);
|
const fuelFu = tripFuel(
|
||||||
|
model,
|
||||||
|
location(contract.originId),
|
||||||
|
location(contract.destinationId),
|
||||||
|
content.config,
|
||||||
|
);
|
||||||
|
// The fuel used, at the tank's average price (decision 0.5.98).
|
||||||
|
const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice);
|
||||||
|
// Profit-share pay for a hired pilot; the avatar is exempt (CR-010).
|
||||||
|
const pilotPay =
|
||||||
|
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
|
||||||
|
|
||||||
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
|
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
|
||||||
let shortOfFuel = false;
|
let shortOfFuel = false;
|
||||||
|
|
@ -279,7 +292,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu },
|
fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu },
|
||||||
shortOfFuel,
|
shortOfFuel,
|
||||||
pilotPay,
|
pilotPay,
|
||||||
netProfit,
|
netProfit: contract.payout - fuelCost - pilotPay,
|
||||||
arrivalAt: now + tripMs,
|
arrivalAt: now + tripMs,
|
||||||
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
|
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
|
||||||
priority:
|
priority:
|
||||||
|
|
@ -363,7 +376,6 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
<Sheet
|
<Sheet
|
||||||
titleId="dispatch-title"
|
titleId="dispatch-title"
|
||||||
eyebrow="Dispatch"
|
eyebrow="Dispatch"
|
||||||
side
|
|
||||||
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
|
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
|
||||||
>
|
>
|
||||||
<p aria-label="Job details" className="mt-1 text-sm text-ink-300">
|
<p aria-label="Job details" className="mt-1 text-sm text-ink-300">
|
||||||
|
|
@ -378,6 +390,9 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
|
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-3">
|
||||||
|
<RouteStrip originId={contract.originId} destinationId={contract.destinationId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-ink-400">
|
<p className="mt-3 text-sm text-ink-400">
|
||||||
|
|
@ -394,10 +409,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
aria-disabled={blocked}
|
aria-disabled={blocked}
|
||||||
className={`block rounded-xl border p-3 text-sm transition ${
|
className={`block rounded-xl border p-3 text-sm transition ${
|
||||||
blocked
|
blocked
|
||||||
? "border-glass-line bg-black/25 opacity-70"
|
? "border-space-700 bg-space-950/40 opacity-70"
|
||||||
: picked
|
: picked
|
||||||
? "cursor-pointer border-action bg-action/8"
|
? "cursor-pointer border-amber-400 bg-amber-500/10 shadow-glow-amber"
|
||||||
: "cursor-pointer border-glass-line bg-white/5 hover:border-glass-line"
|
: "cursor-pointer border-space-600 bg-space-900/60 hover:border-space-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -408,16 +423,18 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
disabled={blocked}
|
disabled={blocked}
|
||||||
checked={picked}
|
checked={picked}
|
||||||
onChange={() => setChosen(option.shipId)}
|
onChange={() => setChosen(option.shipId)}
|
||||||
className="size-4 shrink-0 accent-action"
|
className="size-4 shrink-0 accent-amber-400"
|
||||||
/>
|
/>
|
||||||
<Art sprite={option.modelSprite} className="size-10 shrink-0" />
|
<Art sprite={option.modelSprite} className="size-10 shrink-0" />
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block font-semibold">{option.shipName}</span>
|
<span className="block font-display font-bold tracking-wide">
|
||||||
|
{option.shipName}
|
||||||
|
</span>
|
||||||
<span className="block text-xs text-ink-400">
|
<span className="block text-xs text-ink-400">
|
||||||
{option.modelName} · {option.pilotName ?? "no pilot"}
|
{option.modelName} · {option.pilotName ?? "no pilot"}
|
||||||
</span>
|
</span>
|
||||||
{option.service && (
|
{option.service && (
|
||||||
<span aria-label="Service" className="block text-xs text-ink-100">
|
<span aria-label="Service" className="block text-xs text-cyan-200">
|
||||||
{option.service}
|
{option.service}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -435,13 +452,8 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2">
|
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
<div className="col-span-2">
|
<Cell label="Trip" value={formatDuration(option.tripMs)} />
|
||||||
<dt className="text-xs font-semibold text-ink-300">Net profit</dt>
|
|
||||||
<dd className="text-3xl leading-tight font-semibold text-earned tabular-nums">
|
|
||||||
{formatCredits(option.netProfit)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<Cell
|
<Cell
|
||||||
label="Fuel used (at your average price)"
|
label="Fuel used (at your average price)"
|
||||||
value={formatCredits(option.fuelCost)}
|
value={formatCredits(option.fuelCost)}
|
||||||
|
|
@ -450,7 +462,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
!state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && (
|
!state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && (
|
||||||
<Cell label="Pilot share" value={formatCredits(option.pilotPay)} />
|
<Cell label="Pilot share" value={formatCredits(option.pilotPay)} />
|
||||||
)}
|
)}
|
||||||
<Cell label="Trip" value={formatDuration(option.tripMs)} />
|
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
|
||||||
<Cell
|
<Cell
|
||||||
label="ETA (game)"
|
label="ETA (game)"
|
||||||
value={formatGameTime(
|
value={formatGameTime(
|
||||||
|
|
@ -483,7 +495,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!blocked && option.priority && (
|
{!blocked && option.priority && (
|
||||||
<p aria-label="Priority margin" className="mt-2 text-ink-100">
|
<p aria-label="Priority margin" className="mt-2 text-amber-200">
|
||||||
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
|
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
|
||||||
trip,{" "}
|
trip,{" "}
|
||||||
{formatGameHoursSpan(
|
{formatGameHoursSpan(
|
||||||
|
|
@ -522,11 +534,15 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One number in a pair's outlook; `dt` directly before `dd` ("Trip2h 05m"). */
|
/** One number in a pair's outlook; `dt` directly before `dd` ("Trip2h 05m"). */
|
||||||
function Cell({ label, value }: { label: string; value: string }) {
|
function Cell({ label, value, good }: { label: string; value: string; good?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="rounded-lg bg-space-950/60 px-2 py-1.5 ring-1 ring-space-700">
|
||||||
<dt className="text-xs text-ink-300">{label}</dt>
|
<dt className="font-display text-[10px] tracking-[0.14em] text-ink-400 uppercase">{label}</dt>
|
||||||
<dd className="text-sm font-semibold tabular-nums">{value}</dd>
|
<dd
|
||||||
|
className={`font-display text-sm font-semibold tabular-nums ${good ? "text-emerald-300" : "text-ink-50"}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
validateReposition,
|
validateReposition,
|
||||||
type RepositionFailure,
|
type RepositionFailure,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { FleetViews } from "../components/FleetViews";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
|
|
@ -43,7 +42,7 @@ export function Fleet() {
|
||||||
const discoveredIds = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
|
const discoveredIds = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-4xl">
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title="Fleet"
|
title="Fleet"
|
||||||
eyebrow={
|
eyebrow={
|
||||||
|
|
@ -53,20 +52,19 @@ export function Fleet() {
|
||||||
}
|
}
|
||||||
icon="fleet"
|
icon="fleet"
|
||||||
/>
|
/>
|
||||||
<FleetViews />
|
|
||||||
{state.ships.length === 0 ? (
|
{state.ships.length === 0 ? (
|
||||||
<Panel tone="muted" className="flex flex-col items-center gap-3 py-8 text-center">
|
<Panel tone="muted" className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
<Art sprite="ship-courier" className="size-20 opacity-60" />
|
<Art sprite="ship-courier" className="size-20 opacity-60" />
|
||||||
<p className="text-sm text-ink-300">
|
<p className="text-sm text-ink-300">
|
||||||
No ships yet —{" "}
|
No ships yet —{" "}
|
||||||
<Link to="/market" className="font-semibold underline">
|
<Link to="/market" className="font-semibold text-amber-300 underline">
|
||||||
visit the Shipyard
|
visit the Market
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
{state.ships.map((ship) => (
|
{state.ships.map((ship) => (
|
||||||
<ShipRow
|
<ShipRow
|
||||||
key={ship.id}
|
key={ship.id}
|
||||||
|
|
@ -110,17 +108,19 @@ function ShipRow(props: {
|
||||||
const blocked = findWorkBlock(ship, props.pilot, now);
|
const blocked = findWorkBlock(ship, props.pilot, now);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel as="li" className="flex flex-col gap-3">
|
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative grid size-14 shrink-0 place-items-center">
|
<div className="relative grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
|
||||||
<Art
|
<Art
|
||||||
sprite={model.sprite}
|
sprite={model.sprite}
|
||||||
className={`size-14 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="truncate text-base font-semibold">{ship.name}</span>
|
<span className="truncate font-display text-lg font-bold tracking-wide">
|
||||||
|
{ship.name}
|
||||||
|
</span>
|
||||||
{dispatch ? (
|
{dispatch ? (
|
||||||
<Pill tone="cyan" icon="send">
|
<Pill tone="cyan" icon="send">
|
||||||
In flight
|
In flight
|
||||||
|
|
@ -131,14 +131,14 @@ function ShipRow(props: {
|
||||||
<Pill tone="emerald">Idle</Pill>
|
<Pill tone="emerald">Idle</Pill>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-ink-300">{shipModelName(ship.modelId)}</div>
|
<div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-ink-200">
|
<div className="mt-1 flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-ink-200">
|
||||||
<span>
|
<span>
|
||||||
{dispatch
|
{dispatch
|
||||||
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
||||||
: `At ${locationName(ship.locationId ?? "")}`}
|
: `At ${locationName(ship.locationId ?? "")}`}
|
||||||
{" · "}
|
{" · "}
|
||||||
<span className={pilotName ? "text-ink-300" : "text-problem"}>
|
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
|
||||||
{pilotName ?? "No pilot"}
|
{pilotName ?? "No pilot"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -154,17 +154,15 @@ function ShipRow(props: {
|
||||||
<span>{progress}%</span>
|
<span>{progress}%</span>
|
||||||
<span>
|
<span>
|
||||||
Arrives in{" "}
|
Arrives in{" "}
|
||||||
<span className="text-ink-100">
|
<span className="text-cyan-200">
|
||||||
<Countdown to={dispatch.arrivalAt} />
|
<Countdown to={dispatch.arrivalAt} />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<FuelGauge ship={ship} />
|
||||||
<FuelGauge ship={ship} />
|
<HullGauge ship={ship} />
|
||||||
<HullGauge ship={ship} />
|
|
||||||
</div>
|
|
||||||
<ServiceTag ship={ship} />
|
<ServiceTag ship={ship} />
|
||||||
{low && (
|
{low && (
|
||||||
<p
|
<p
|
||||||
|
|
@ -172,14 +170,14 @@ function ShipRow(props: {
|
||||||
aria-label={`${ship.name} low fuel`}
|
aria-label={`${ship.name} low fuel`}
|
||||||
className="flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
className="flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
||||||
>
|
>
|
||||||
<span className="font-display font-semibold">Low fuel</span>
|
<span className="font-display font-semibold tracking-wide uppercase">Low fuel</span>
|
||||||
<span className="text-rose-200/80">
|
<span className="text-rose-200/80">
|
||||||
{locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
|
{locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{inShop && (
|
{inShop && (
|
||||||
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-ink-100">
|
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
|
||||||
{ship.repairing ? "Under repair" : "In the shop"} —{" "}
|
{ship.repairing ? "Under repair" : "In the shop"} —{" "}
|
||||||
<Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
|
<Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -219,18 +217,17 @@ function ShipRow(props: {
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={props.onDeadhead}
|
onClick={props.onDeadhead}
|
||||||
aria-label="Deadhead (fly empty)"
|
className="flex-1 basis-40"
|
||||||
className="flex-1 basis-32"
|
|
||||||
>
|
>
|
||||||
<Icon name="send" className="size-4" />
|
<Icon name="send" className="size-4" />
|
||||||
Fly empty
|
Deadhead (fly empty)
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={props.onFuel}
|
onClick={props.onFuel}
|
||||||
aria-label={`Fuel for ${ship.name}`}
|
aria-label={`Fuel for ${ship.name}`}
|
||||||
|
|
@ -239,7 +236,7 @@ function ShipRow(props: {
|
||||||
Fuel
|
Fuel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={props.onUpgrades}
|
onClick={props.onUpgrades}
|
||||||
aria-label={`Upgrades for ${ship.name}`}
|
aria-label={`Upgrades for ${ship.name}`}
|
||||||
|
|
@ -381,12 +378,14 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
{ onSuccess: onClose },
|
{ onSuccess: onClose },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex w-full items-center gap-3 rounded-xl border border-glass-line bg-white/5 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="flex w-full items-center gap-3 rounded-xl border border-space-600 bg-space-900/60 p-3 text-left transition hover:border-amber-400/70 hover:bg-amber-500/10 active:scale-[0.99] disabled:pointer-events-none disabled:opacity-55"
|
||||||
>
|
>
|
||||||
{sprite && <Art sprite={sprite} className="size-10 shrink-0" />}
|
{sprite && <Art sprite={sprite} className="size-10 shrink-0" />}
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex items-baseline justify-between gap-2">
|
<span className="flex items-baseline justify-between gap-2">
|
||||||
<span className="font-display font-bold">{locationName(option.id)}</span>
|
<span className="font-display font-bold tracking-wide">
|
||||||
|
{locationName(option.id)}
|
||||||
|
</span>
|
||||||
<span className="font-display text-sm text-ink-300 tabular-nums">
|
<span className="font-display text-sm text-ink-300 tabular-nums">
|
||||||
{formatCredits(option.fuelCost)} fuel used · {formatDuration(option.tripMs)}
|
{formatCredits(option.fuelCost)} fuel used · {formatDuration(option.tripMs)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -426,7 +425,7 @@ export function HullGauge({ ship }: { ship: Ship }) {
|
||||||
const tone = pct < needsRepairBelowPct ? "rose" : pct < noDangerousBelowPct ? "amber" : "emerald";
|
const tone = pct < needsRepairBelowPct ? "rose" : pct < noDangerousBelowPct ? "amber" : "emerald";
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline justify-between font-display text-xs text-ink-400">
|
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
<span>Hull</span>
|
<span>Hull</span>
|
||||||
<span className="tracking-normal normal-case">{Math.round(pct)}%</span>
|
<span className="tracking-normal normal-case">{Math.round(pct)}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -437,7 +436,7 @@ export function HullGauge({ ship }: { ship: Ship }) {
|
||||||
aria-label={`${ship.name} needs repair`}
|
aria-label={`${ship.name} needs repair`}
|
||||||
className="mt-2 flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
className="mt-2 flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
||||||
>
|
>
|
||||||
<span className="font-display font-semibold">Needs repair</span>
|
<span className="font-display font-semibold tracking-wide uppercase">Needs repair</span>
|
||||||
<span className="text-rose-200/80">Repair it at an HQ</span>
|
<span className="text-rose-200/80">Repair it at an HQ</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { PlacesViews } from "../components/FleetViews";
|
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Bay } from "@deadhead/schema";
|
import type { Bay } from "@deadhead/schema";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
@ -16,14 +15,13 @@ export const HQ_ALLOWS = "Hire pilots here and take delivery of new ships here."
|
||||||
export function HQs() {
|
export function HQs() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-4xl">
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title="HQs"
|
title="HQs"
|
||||||
eyebrow="Your bases"
|
eyebrow="Your bases"
|
||||||
icon="hq"
|
icon="hq"
|
||||||
subtitle={state.bays.length > 0 ? HQ_ALLOWS : undefined}
|
subtitle={state.bays.length > 0 ? HQ_ALLOWS : undefined}
|
||||||
/>
|
/>
|
||||||
<PlacesViews />
|
|
||||||
{state.bays.length === 0 ? (
|
{state.bays.length === 0 ? (
|
||||||
<Panel tone="muted" className="space-y-3 text-sm">
|
<Panel tone="muted" className="space-y-3 text-sm">
|
||||||
<p className="text-ink-200">You don't have an HQ yet.</p>
|
<p className="text-ink-200">You don't have an HQ yet.</p>
|
||||||
|
|
@ -36,7 +34,7 @@ export function HQs() {
|
||||||
</Link>
|
</Link>
|
||||||
</Panel>
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-4 @2xl:grid-cols-2">
|
<ul className="grid gap-4 md:grid-cols-2">
|
||||||
{state.bays.map((bay) => (
|
{state.bays.map((bay) => (
|
||||||
<HqCard key={bay.id} bay={bay} />
|
<HqCard key={bay.id} bay={bay} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -60,7 +58,7 @@ function HqCard({ bay }: { bay: Bay }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel as="li" aria-label={`HQ at ${name}`} className="text-sm">
|
<Panel as="li" feature tone="emerald" aria-label={`HQ at ${name}`} className="text-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{location && (
|
{location && (
|
||||||
<Art
|
<Art
|
||||||
|
|
@ -69,7 +67,7 @@ function HqCard({ bay }: { bay: Bay }) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="font-display text-xl font-bold">{name}</h2>
|
<h2 className="font-display text-xl font-bold tracking-wide">{name}</h2>
|
||||||
<p className="text-ink-300">
|
<p className="text-ink-300">
|
||||||
Fuel: <FuelPrice locationId={bay.locationId} />
|
Fuel: <FuelPrice locationId={bay.locationId} />
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -112,7 +110,7 @@ function HqCard({ bay }: { bay: Bay }) {
|
||||||
{state.ships.find((s) => s.id === d.shipId)?.name ?? "A ship"} from{" "}
|
{state.ships.find((s) => s.id === d.shipId)?.name ?? "A ship"} from{" "}
|
||||||
{locationName(d.originId)}
|
{locationName(d.originId)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-ink-100">
|
<span className="font-display text-cyan-200">
|
||||||
<Countdown to={d.arrivalAt} />
|
<Countdown to={d.arrivalAt} />
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
import type { Contract } from "@deadhead/schema";
|
|
||||||
import { Countdown } from "../components/Countdown";
|
|
||||||
import { ClassChip, goodName, PriorityChip } from "../components/Goods";
|
|
||||||
import {
|
|
||||||
formatClock,
|
|
||||||
formatCredits,
|
|
||||||
formatGameHours,
|
|
||||||
formatMillionKm,
|
|
||||||
locationName,
|
|
||||||
} from "../format";
|
|
||||||
import { distanceAt } from "../game";
|
|
||||||
import { useClock } from "../clock/clock";
|
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
|
||||||
import { usePreferences, type JobSort } from "../state/preferences";
|
|
||||||
import { Pill } from "../ui";
|
|
||||||
import { jobFigures, type JobFigures } from "./jobFigures";
|
|
||||||
import { requirementsText } from "./LocationBoards";
|
|
||||||
|
|
||||||
const COLUMNS = "hidden px-2 text-[13px] text-ink-300 @2xl:table-cell";
|
|
||||||
|
|
||||||
const money = (n: number) => n.toLocaleString("en-US");
|
|
||||||
|
|
||||||
function netLabel({ net, estimate }: JobFigures): string {
|
|
||||||
if (net === null) return "—";
|
|
||||||
return `${estimate ? "≈" : ""}${net >= 0 ? "+" : "−"}${money(Math.abs(net))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Jobs at a place, sortable by net or credits per hour (decision 0.5.169). */
|
|
||||||
export function JobsTable({
|
|
||||||
contracts,
|
|
||||||
fresh,
|
|
||||||
firstLookMs,
|
|
||||||
pilotsOnTheirOwn,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
contracts: Contract[];
|
|
||||||
fresh: ReadonlySet<string>;
|
|
||||||
firstLookMs: number;
|
|
||||||
pilotsOnTheirOwn: boolean;
|
|
||||||
onSelect?: (contract: Contract) => void;
|
|
||||||
}) {
|
|
||||||
const state = useLoadedPlayer();
|
|
||||||
const now = useClock((s) => s.now);
|
|
||||||
const sort = usePreferences((p) => p.jobSort);
|
|
||||||
const setPreference = usePreferences((p) => p.set);
|
|
||||||
const value = (f: JobFigures) => (sort === "net" ? f.net : f.perHour) ?? -Infinity;
|
|
||||||
|
|
||||||
const rows = contracts
|
|
||||||
.map((contract) => ({ contract, figures: jobFigures(state, contract, now) }))
|
|
||||||
.sort((a, b) => value(b.figures) - value(a.figures));
|
|
||||||
const ready = rows.filter((r) => r.figures.ready);
|
|
||||||
const blocked = rows.filter((r) => !r.figures.ready);
|
|
||||||
|
|
||||||
const header = (key: JobSort, label: string, width: string) => (
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
aria-sort={sort === key ? "descending" : "none"}
|
|
||||||
className={`p-0 text-right font-medium ${width}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPreference({ jobSort: key })}
|
|
||||||
className={`flex min-h-11 w-full items-center justify-end gap-1 pr-3 text-xs font-semibold ${
|
|
||||||
sort === key ? "text-ink-50" : "text-ink-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{sort === key && <span aria-hidden="true">↓</span>}
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
|
|
||||||
const row = ({ contract, figures }: (typeof rows)[number]) => {
|
|
||||||
const requirements = requirementsText(contract);
|
|
||||||
const firstLook =
|
|
||||||
pilotsOnTheirOwn && !contract.firstContact && now < contract.offeredAt + firstLookMs;
|
|
||||||
const hours = figures.hours === null ? null : formatGameHours(figures.hours);
|
|
||||||
const destination = `To ${locationName(contract.destinationId)}`;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={contract.id}
|
|
||||||
aria-label={goodName(contract)}
|
|
||||||
className={`relative border-t border-glass-line ${
|
|
||||||
contract.firstContact ? "bg-action/8" : ""
|
|
||||||
} ${fresh.has(contract.id) ? "animate-rise" : ""}`}
|
|
||||||
>
|
|
||||||
<td className="min-w-0 py-2 pr-1 pl-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
|
||||||
<span
|
|
||||||
className={`text-[14.5px] leading-snug font-semibold ${figures.ready ? "" : "text-ink-300"}`}
|
|
||||||
>
|
|
||||||
{goodName(contract)}
|
|
||||||
</span>
|
|
||||||
{fresh.has(contract.id) && (
|
|
||||||
<Pill tone="cyan" icon="star">
|
|
||||||
New
|
|
||||||
</Pill>
|
|
||||||
)}
|
|
||||||
{contract.cargoTypeId !== "standard" && <ClassChip contract={contract} />}
|
|
||||||
{contract.priority && contract.deliveryWindowGameHours !== null && (
|
|
||||||
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
|
|
||||||
)}
|
|
||||||
{contract.firstContact && (
|
|
||||||
<Pill tone="amber" icon="star">
|
|
||||||
First contact
|
|
||||||
</Pill>
|
|
||||||
)}
|
|
||||||
{firstLook && (
|
|
||||||
<Pill tone="amber" icon="clock">
|
|
||||||
<span aria-label="Yours for">
|
|
||||||
Yours for{" "}
|
|
||||||
<Countdown
|
|
||||||
to={contract.offeredAt + firstLookMs}
|
|
||||||
format={formatClock}
|
|
||||||
done="now"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Pill>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[12.5px] leading-snug text-ink-300">
|
|
||||||
{destination}
|
|
||||||
<span className="@2xl:hidden">
|
|
||||||
{" "}
|
|
||||||
· <span>{contract.massTonnes} t</span>
|
|
||||||
{hours && <span aria-label={`Takes ${hours} of game time`}> · {hours}</span>}
|
|
||||||
</span>
|
|
||||||
{requirements && <span className="@2xl:hidden"> · Needs {requirements}</span>}
|
|
||||||
{figures.note && <span> · {figures.note}</span>}
|
|
||||||
</div>
|
|
||||||
{/* The rest of the job: beside the columns on desktop, in the sheet on phones. */}
|
|
||||||
<div className="hidden text-xs leading-snug text-ink-400 @2xl:block">
|
|
||||||
Pays <span>{formatCredits(contract.payout)}</span> ·{" "}
|
|
||||||
{formatMillionKm(distanceAt(contract.originId, contract.destinationId))} ·{" "}
|
|
||||||
{requirements && `Needs ${requirements} · `}expires in{" "}
|
|
||||||
<Countdown to={contract.expiresAt} done="expired" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className={COLUMNS}>{contract.shipper}</td>
|
|
||||||
<td className={`${COLUMNS} tabular-nums`}>{contract.massTonnes} t</td>
|
|
||||||
<td className={`${COLUMNS} tabular-nums`}>{hours ?? "—"}</td>
|
|
||||||
<td
|
|
||||||
aria-label={figures.estimate ? "Net, estimated" : "Net"}
|
|
||||||
className={`pr-3 text-right text-[15px] font-bold whitespace-nowrap tabular-nums ${
|
|
||||||
figures.ready ? "text-earned" : "text-ink-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{netLabel(figures)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
aria-label="Credits per hour"
|
|
||||||
className="pr-3 text-right text-[13.5px] whitespace-nowrap text-ink-300 tabular-nums"
|
|
||||||
>
|
|
||||||
{figures.perHour === null ? "—" : money(figures.perHour)}
|
|
||||||
</td>
|
|
||||||
{onSelect && (
|
|
||||||
<td className="w-0 p-0">
|
|
||||||
{/* The whole row opens the job: the button stretches over it. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Dispatch"
|
|
||||||
onClick={() => onSelect(contract)}
|
|
||||||
className="absolute inset-0 rounded-sm hover:bg-white/5 focus-visible:-outline-offset-2"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-glass-line">
|
|
||||||
<table className="w-full border-collapse text-left">
|
|
||||||
<caption className="sr-only">Jobs here, best first</caption>
|
|
||||||
<thead className="bg-black/20">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" className="py-0 pl-3 text-xs font-semibold text-ink-300">
|
|
||||||
Job
|
|
||||||
</th>
|
|
||||||
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
|
|
||||||
Shipper
|
|
||||||
</th>
|
|
||||||
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
|
|
||||||
Weight
|
|
||||||
</th>
|
|
||||||
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
|
|
||||||
Hours
|
|
||||||
</th>
|
|
||||||
{header("net", "Net", "w-20")}
|
|
||||||
{header("rate", "cr/h", "w-16")}
|
|
||||||
{onSelect && (
|
|
||||||
<td className="w-0 p-0">
|
|
||||||
<span className="sr-only">Open</span>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ready.map(row)}
|
|
||||||
{blocked.length > 0 && (
|
|
||||||
<tr className="border-t border-glass-line bg-black/20">
|
|
||||||
<th
|
|
||||||
scope="colgroup"
|
|
||||||
colSpan={onSelect ? 7 : 6}
|
|
||||||
className="px-3 py-1.5 text-xs font-semibold text-ink-300"
|
|
||||||
>
|
|
||||||
Can't take yet
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{blocked.map(row)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,14 +5,22 @@ import { useSearchParams } from "react-router";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { serverNow, useClock } from "../clock/clock";
|
import { serverNow, useClock } from "../clock/clock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
import { formatClock, locationName, skillName } from "../format";
|
import {
|
||||||
import { notReady, readyShips } from "../game";
|
formatClock,
|
||||||
|
formatCredits,
|
||||||
|
formatGameHours,
|
||||||
|
formatMillionKm,
|
||||||
|
locationName,
|
||||||
|
skillName,
|
||||||
|
} from "../format";
|
||||||
|
import { distanceAt, jobGameHours, notReady, readyShips } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { pairOptions } from "./DispatchSheet";
|
import { pairOptions } from "./DispatchSheet";
|
||||||
import { JobsTable } from "./JobsTable";
|
|
||||||
import { DeadheadPicker } from "./Fleet";
|
import { DeadheadPicker } from "./Fleet";
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { Art, Button, Icon, Panel } from "../ui";
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
|
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
||||||
|
import { ClassChip, goodName, PriorityChip } from "../components/Goods";
|
||||||
|
|
||||||
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
||||||
|
|
||||||
|
|
@ -108,14 +116,14 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={id === locationId}
|
aria-pressed={id === locationId}
|
||||||
onClick={() => setParams({ location: id }, { replace: true })}
|
onClick={() => setParams({ location: id }, { replace: true })}
|
||||||
className="flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border border-glass-line py-1 pr-3 pl-1 text-[13px] font-medium text-ink-300 transition hover:text-ink-50 aria-pressed:border-white/50 aria-pressed:bg-white/14 aria-pressed:text-ink-50"
|
className="flex shrink-0 items-center gap-2 rounded-full border border-space-600 bg-space-900/70 py-1 pr-3 pl-1 font-display text-sm font-semibold tracking-wide text-ink-300 backdrop-blur transition hover:border-space-500 hover:text-ink-50 aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-100 aria-pressed:shadow-glow-amber"
|
||||||
>
|
>
|
||||||
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
|
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
|
||||||
{locationName(id)}
|
{locationName(id)}
|
||||||
{ready > 0 && (
|
{ready > 0 && (
|
||||||
<span
|
<span
|
||||||
aria-label={`${ready} ${ready === 1 ? "ship" : "ships"} ready`}
|
aria-label={`${ready} ${ready === 1 ? "ship" : "ships"} ready`}
|
||||||
className="grid h-5 min-w-5 place-items-center rounded-full bg-action/20 px-1.5 text-xs font-semibold text-action tabular-nums"
|
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"
|
||||||
>
|
>
|
||||||
{ready}
|
{ready}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -166,16 +174,19 @@ function LocationBoard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const refresh = (
|
const refresh = (
|
||||||
<p aria-label="Board refresh" className="flex items-center gap-1.5 text-xs text-ink-300">
|
<p
|
||||||
<Icon name="clock" className="size-3.5" />
|
aria-label="Board refresh"
|
||||||
|
className="flex items-center gap-1.5 font-display text-xs tracking-wider text-ink-400 uppercase"
|
||||||
|
>
|
||||||
|
<Icon name="clock" className="size-3.5 text-cyan-300" />
|
||||||
New contracts in{" "}
|
New contracts in{" "}
|
||||||
<span className="font-semibold text-ink-100 tabular-nums">
|
<span className="text-cyan-200">
|
||||||
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
||||||
</span>
|
</span>
|
||||||
{fresh.size > 0 && (
|
{fresh.size > 0 && (
|
||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
className="ml-2 animate-rise rounded-full border border-glass-line bg-white/5 px-2 py-0.5 text-ink-100 normal-case"
|
className="ml-2 animate-rise rounded-full border border-cyan-400/50 bg-cyan-500/15 px-2 py-0.5 tracking-wider text-cyan-100 normal-case"
|
||||||
>
|
>
|
||||||
New contracts posted
|
New contracts posted
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -189,7 +200,7 @@ function LocationBoard({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Panel tone="muted" role="status" className="text-center text-sm text-ink-300">
|
<Panel tone="muted" role="status" className="text-center text-sm text-ink-300">
|
||||||
No jobs here your fleet can take right now. New contracts in{" "}
|
No jobs here your fleet can take right now. New contracts in{" "}
|
||||||
<span className="font-display text-ink-100 tabular-nums">
|
<span className="font-display text-cyan-200 tabular-nums">
|
||||||
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
||||||
</span>
|
</span>
|
||||||
.
|
.
|
||||||
|
|
@ -203,17 +214,110 @@ function LocationBoard({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{refresh}
|
{refresh}
|
||||||
{stuck}
|
{stuck}
|
||||||
<JobsTable
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
contracts={board.data.contracts}
|
{board.data.contracts.map((contract) => {
|
||||||
fresh={fresh}
|
const requirements = requirementsText(contract);
|
||||||
firstLookMs={firstLookMs}
|
return (
|
||||||
pilotsOnTheirOwn={pilotsOnTheirOwn}
|
<Panel
|
||||||
onSelect={onSelect}
|
as="li"
|
||||||
/>
|
key={contract.id}
|
||||||
|
tone={contract.firstContact ? "amber" : "default"}
|
||||||
|
feature={contract.firstContact}
|
||||||
|
className={`flex flex-col gap-2 ${fresh.has(contract.id) ? "animate-rise" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-display text-sm font-semibold tracking-wide text-ink-300">
|
||||||
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
|
||||||
|
{formatCredits(contract.payout)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display text-lg leading-tight font-bold tracking-wide">
|
||||||
|
{goodName(contract)}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
{fresh.has(contract.id) && (
|
||||||
|
<Pill tone="cyan" icon="star">
|
||||||
|
New
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
<ClassChip contract={contract} />
|
||||||
|
{contract.priority && contract.deliveryWindowGameHours !== null && (
|
||||||
|
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
|
||||||
|
)}
|
||||||
|
{contract.firstContact && (
|
||||||
|
<Pill tone="amber" icon="star">
|
||||||
|
First contact
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
{pilotsOnTheirOwn &&
|
||||||
|
!contract.firstContact &&
|
||||||
|
now < contract.offeredAt + firstLookMs && (
|
||||||
|
<Pill tone="amber" icon="clock">
|
||||||
|
<span aria-label="Yours for">
|
||||||
|
Yours for{" "}
|
||||||
|
<Countdown
|
||||||
|
to={contract.offeredAt + firstLookMs}
|
||||||
|
format={formatClock}
|
||||||
|
done="now"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
{contract.shipper} · <span>{contract.massTonnes} t</span> ·{" "}
|
||||||
|
{formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
|
||||||
|
<TripHours contract={contract} />
|
||||||
|
</span>
|
||||||
|
</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" />
|
||||||
|
</span>
|
||||||
|
{onSelect && (
|
||||||
|
<Button size="sm" onClick={() => onSelect(contract)} className="shrink-0">
|
||||||
|
Dispatch
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** " · 5.4 h": the job's length in game hours for the fastest ship that could take it. */
|
||||||
|
function TripHours({ contract }: { contract: Contract }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const hours = jobGameHours(state, contract);
|
||||||
|
if (hours === null) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={`Takes ${formatGameHours(hours)} of game time`}
|
||||||
|
title="Game hours, for your fastest ship that can take it"
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
· {formatGameHours(hours)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why no ship here can take a job now (decision 0.5.134): none docked, or the
|
* Why no ship here can take a job now (decision 0.5.134): none docked, or the
|
||||||
* pilots here resting (with who wakes first), or none crewed for the player.
|
* pilots here resting (with who wakes first), or none crewed for the player.
|
||||||
|
|
@ -226,8 +330,8 @@ function NoneReady({ locationId, now }: { locationId: string; now: number }) {
|
||||||
.flatMap((r) => (r?.reason === "resting" ? [r.pilot] : []))
|
.flatMap((r) => (r?.reason === "resting" ? [r.pilot] : []))
|
||||||
.sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0];
|
.sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0];
|
||||||
return (
|
return (
|
||||||
<p className="flex items-center gap-2 rounded-xl border border-glass-line px-3 py-2 text-sm text-ink-300">
|
<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={waking ? "rest" : "alert"} className="size-4 shrink-0" />
|
<Icon name={waking ? "rest" : "alert"} className="size-4 shrink-0 text-amber-300" />
|
||||||
{idle.length === 0 ? (
|
{idle.length === 0 ? (
|
||||||
"No idle ships here. Deadhead one in from the Fleet screen to take these jobs."
|
"No idle ships here. Deadhead one in from the Fleet screen to take these jobs."
|
||||||
) : waking ? (
|
) : waking ? (
|
||||||
|
|
@ -278,10 +382,11 @@ function NothingHere({
|
||||||
key={ship.id}
|
key={ship.id}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`Nothing for ${ship.name}`}
|
aria-label={`Nothing for ${ship.name}`}
|
||||||
|
tone="cyan"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
<p className="flex items-center gap-2 text-ink-100">
|
<p className="flex items-center gap-2 text-cyan-100">
|
||||||
<Icon name="alert" className="size-4 shrink-0 text-ink-300" />
|
<Icon name="alert" className="size-4 shrink-0 text-cyan-300" />
|
||||||
No job here that {ship.name} and {pilot.name} can take.
|
No job here that {ship.name} and {pilot.name} can take.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { PlacesViews } from "../components/FleetViews";
|
|
||||||
import { content, type Location } from "@deadhead/content";
|
import { content, type Location } from "@deadhead/content";
|
||||||
import { FuelPrice } from "../components/Fuel";
|
import { FuelPrice } from "../components/Fuel";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
@ -27,15 +26,14 @@ export function Locations() {
|
||||||
const lease = useGameAction("leaseBay");
|
const lease = useGameAction("leaseBay");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-4xl">
|
||||||
<ScreenHeader title="Places" icon="places" />
|
<ScreenHeader title="Locations" eyebrow="Known space" icon="places" />
|
||||||
<PlacesViews />
|
|
||||||
{lease.isError && (
|
{lease.isError && (
|
||||||
<p role="alert" className="text-sm text-rose-300">
|
<p role="alert" className="text-sm text-rose-300">
|
||||||
{errorMessage(lease.error)}
|
{errorMessage(lease.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
{content.locations.map((location) => {
|
{content.locations.map((location) => {
|
||||||
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
|
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
|
||||||
if (!discovered) {
|
if (!discovered) {
|
||||||
|
|
@ -54,7 +52,9 @@ export function Locations() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-display text-lg font-bold text-ink-300">{location.name}</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 className="text-ink-400">Undiscovered — deliver here to unlock.</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
@ -76,30 +76,42 @@ export function Locations() {
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="font-display text-xl font-bold">{location.name}</span>
|
<span className="font-display text-xl font-bold tracking-wide">
|
||||||
|
{location.name}
|
||||||
|
</span>
|
||||||
{hasBay && (
|
{hasBay && (
|
||||||
<Pill tone="emerald" icon="hq">
|
<Pill tone="emerald" icon="hq">
|
||||||
HQ
|
HQ
|
||||||
</Pill>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-display text-xs text-ink-300">
|
<span className="font-display text-xs tracking-wider text-cyan-300/80">
|
||||||
{location.id === "earth"
|
{location.id === "earth"
|
||||||
? "Home"
|
? "Home"
|
||||||
: `${formatMillionKm(distanceAt("earth", location.id))} from Earth`}
|
: `${formatMillionKm(distanceAt("earth", location.id))} from Earth`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg bg-black/25 p-2.5 text-ink-200 ring-1 ring-glass-line">
|
<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-xs text-ink-400">Type</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Type
|
||||||
|
</dt>
|
||||||
<dd>{TYPE_LABELS[location.type]}</dd>
|
<dd>{TYPE_LABELS[location.type]}</dd>
|
||||||
<dt className="font-display text-xs text-ink-400">Landing</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Landing
|
||||||
|
</dt>
|
||||||
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
|
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
|
||||||
<dt className="font-display text-xs text-ink-400">Produces</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Produces
|
||||||
|
</dt>
|
||||||
<dd>{location.cargoClassMix.produces.map(cargoName).join(", ") || "—"}</dd>
|
<dd>{location.cargoClassMix.produces.map(cargoName).join(", ") || "—"}</dd>
|
||||||
<dt className="font-display text-xs text-ink-400">Demands</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Demands
|
||||||
|
</dt>
|
||||||
<dd>{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}</dd>
|
<dd>{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}</dd>
|
||||||
<dt className="font-display text-xs text-ink-400">Fuel</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Fuel
|
||||||
|
</dt>
|
||||||
<dd aria-label={`Fuel at ${location.name}`}>
|
<dd aria-label={`Fuel at ${location.name}`}>
|
||||||
<FuelPrice locationId={location.id} />
|
<FuelPrice locationId={location.id} />
|
||||||
</dd>
|
</dd>
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export function Logbook() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-3xl">
|
||||||
<ScreenHeader title="Logbook" eyebrow="The last 7 days" icon="route" />
|
<ScreenHeader title="Logbook" eyebrow="The last 7 days" icon="route" />
|
||||||
<Panel className="flex flex-wrap items-end gap-3 text-sm" aria-label="Logbook filters">
|
<Panel className="flex flex-wrap items-end gap-3 text-sm" aria-label="Logbook filters">
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
|
|
@ -65,7 +65,7 @@ export function Logbook() {
|
||||||
<select
|
<select
|
||||||
value={pilotId}
|
value={pilotId}
|
||||||
onChange={(e) => set("pilot", e.target.value)}
|
onChange={(e) => set("pilot", e.target.value)}
|
||||||
className="min-h-11 rounded-lg border border-glass-line bg-space-900 px-2"
|
className="rounded-lg border border-space-600 bg-space-900 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<option value="">All pilots</option>
|
<option value="">All pilots</option>
|
||||||
{state.pilots.map((p) => (
|
{state.pilots.map((p) => (
|
||||||
|
|
@ -77,11 +77,11 @@ export function Logbook() {
|
||||||
</label>
|
</label>
|
||||||
<fieldset className="flex flex-col gap-1">
|
<fieldset className="flex flex-col gap-1">
|
||||||
<legend className="text-ink-400">Time</legend>
|
<legend className="text-ink-400">Time</legend>
|
||||||
<div className="flex overflow-hidden rounded-lg border border-glass-line">
|
<div className="flex overflow-hidden rounded-lg border border-space-600">
|
||||||
{RANGES.map(([value, label]) => (
|
{RANGES.map(([value, label]) => (
|
||||||
<label
|
<label
|
||||||
key={value}
|
key={value}
|
||||||
className="cursor-pointer px-3 py-1.5 has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
|
className="cursor-pointer px-3 py-1.5 has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { content, type Location } from "@deadhead/content";
|
import { content, type Location } from "@deadhead/content";
|
||||||
import { FuelPrice } from "../components/Fuel";
|
import { FuelPrice } from "../components/Fuel";
|
||||||
import { realToGame } from "@deadhead/sim";
|
import { realToGame } from "@deadhead/sim";
|
||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { formatGameTime } from "../clock/GameClock";
|
import { formatGameTime } from "../clock/GameClock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
import { cargoName, formatMillionKm, locationName } from "../format";
|
import { cargoName, formatMillionKm, locationName } from "../format";
|
||||||
import { distanceAt } from "../game";
|
import { distanceAt } from "../game";
|
||||||
import { useMapSelection } from "../map/selection";
|
import { resetMapView } from "../map/camera";
|
||||||
|
import type { MapSelection } from "../map/hitTest";
|
||||||
|
import { MapCanvas } from "../map/MapCanvas";
|
||||||
import { reachersByOrigin } from "../map/reach";
|
import { reachersByOrigin } from "../map/reach";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
import { usePreferences } from "../state/preferences";
|
import { usePreferences } from "../state/preferences";
|
||||||
|
|
@ -22,22 +24,32 @@ const TYPE_LABELS: Record<Location["type"], string> = {
|
||||||
"surface-base": "Surface base",
|
"surface-base": "Surface base",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The map view (Phase 10): the map itself is the backdrop. Tap a place or ship for details. */
|
/** The solar system map (Phase 10). Tap a location or ship for details. */
|
||||||
export function MapScreen() {
|
export function MapScreen() {
|
||||||
const selection = useMapSelection((s) => s.selection);
|
const state = useLoadedPlayer();
|
||||||
const select = useMapSelection((s) => s.select);
|
const [selection, setSelection] = useState<MapSelection | null>(null);
|
||||||
const close = () => select(null);
|
|
||||||
|
|
||||||
return (
|
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))]">
|
||||||
<h1 className="sr-only">Map</h1>
|
<h1 className="sr-only">Map</h1>
|
||||||
|
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetMapView}
|
||||||
|
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
|
||||||
|
>
|
||||||
|
Reset view
|
||||||
|
</button>
|
||||||
{selection?.kind === "location" && (
|
{selection?.kind === "location" && (
|
||||||
<LocationPanel locationId={selection.id} onClose={close} />
|
<LocationPanel locationId={selection.id} onClose={() => setSelection(null)} />
|
||||||
)}
|
)}
|
||||||
{selection?.kind === "flight" && (
|
{selection?.kind === "flight" && (
|
||||||
<FlightPanel dispatchId={selection.dispatchId} onClose={close} />
|
<FlightPanel dispatchId={selection.dispatchId} onClose={() => setSelection(null)} />
|
||||||
)}
|
)}
|
||||||
</>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,13 +68,13 @@ function Panel({
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className="pointer-events-auto animate-sheet rounded-3xl border border-glass-line bg-glass p-4 text-sm shadow-panel md:backdrop-blur-xl"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{art}
|
{art}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<h2 className="font-display text-lg font-bold">{title}</h2>
|
<h2 className="font-display text-lg font-bold tracking-wide">{title}</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -171,7 +183,7 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: ()
|
||||||
<p className="text-ink-300">Pilot: {pilotName}</p>
|
<p className="text-ink-300">Pilot: {pilotName}</p>
|
||||||
<p className="text-ink-300">
|
<p className="text-ink-300">
|
||||||
Arrives in{" "}
|
Arrives in{" "}
|
||||||
<span className="font-display text-ink-100">
|
<span className="font-display text-cyan-200">
|
||||||
<Countdown to={dispatch.arrivalAt} />
|
<Countdown to={dispatch.arrivalAt} />
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
({formatGameTime(realToGame(dispatch.arrivalAt, content.config), clockFormat)})
|
({formatGameTime(realToGame(dispatch.arrivalAt, content.config), clockFormat)})
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,10 @@ export function Market() {
|
||||||
const [buying, setBuying] = useState<ShipModel | null>(null);
|
const [buying, setBuying] = useState<ShipModel | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-5xl">
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title="Shipyard"
|
title="Market"
|
||||||
|
eyebrow="Shipyard"
|
||||||
icon="market"
|
icon="market"
|
||||||
subtitle="New ships are delivered with no pilot to one of your HQs."
|
subtitle="New ships are delivered with no pilot to one of your HQs."
|
||||||
/>
|
/>
|
||||||
|
|
@ -47,21 +48,22 @@ export function Market() {
|
||||||
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
|
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="grid gap-4 @2xl:grid-cols-3">
|
<ul className="grid gap-4 md:grid-cols-3">
|
||||||
{MODELS.map((model) => {
|
{MODELS.map((model) => {
|
||||||
const affordable = state.company.credits >= model.price;
|
const affordable = state.company.credits >= model.price;
|
||||||
return (
|
return (
|
||||||
<Panel as="li" key={model.id} className="flex flex-col text-sm">
|
<Panel as="li" key={model.id} feature={affordable} className="flex flex-col text-sm">
|
||||||
<div className="relative -mt-1 grid h-32 place-items-center">
|
<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
|
<Art
|
||||||
sprite={model.sprite}
|
sprite={model.sprite}
|
||||||
className="relative size-28 drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
|
className="relative size-32 rotate-[30deg] drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-baseline justify-between gap-2">
|
<div className="mt-2 flex items-baseline justify-between gap-2">
|
||||||
<span className="text-lg font-semibold">{model.name}</span>
|
<span className="font-display text-xl font-bold tracking-wide">{model.name}</span>
|
||||||
<span
|
<span
|
||||||
className={`font-semibold tabular-nums ${affordable ? "text-ink-50" : "text-ink-300"}`}
|
className={`font-display text-base font-bold tabular-nums ${affordable ? "text-amber-300" : "text-ink-400"}`}
|
||||||
>
|
>
|
||||||
{formatCredits(model.price)}
|
{formatCredits(model.price)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -92,7 +94,6 @@ export function Market() {
|
||||||
<OwnedUpgraded modelId={model.id} />
|
<OwnedUpgraded modelId={model.id} />
|
||||||
<Button
|
<Button
|
||||||
aria-label={`Buy ${model.name}`}
|
aria-label={`Buy ${model.name}`}
|
||||||
variant="secondary"
|
|
||||||
block
|
block
|
||||||
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
||||||
onClick={() => setBuying(model)}
|
onClick={() => setBuying(model)}
|
||||||
|
|
@ -121,19 +122,18 @@ export function Market() {
|
||||||
/** One spec line: label and value (dt directly before dd), with a bar relative to the best model. */
|
/** 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 }) {
|
function Spec(props: { icon: IconName; label: string; value: string; share?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2">
|
<div>
|
||||||
<dt className="flex items-center gap-1.5 text-xs text-ink-300">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Icon name={props.icon} className="size-3.5" />
|
<dt className="flex items-center gap-1.5 font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
{props.label}
|
<Icon name={props.icon} className="size-3.5" />
|
||||||
</dt>
|
{props.label}
|
||||||
<dd className="font-semibold tabular-nums">{props.value}</dd>
|
</dt>
|
||||||
|
<dd className="font-display font-semibold tabular-nums">{props.value}</dd>
|
||||||
|
</div>
|
||||||
{props.share !== undefined && (
|
{props.share !== undefined && (
|
||||||
<div
|
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
|
||||||
aria-hidden="true"
|
|
||||||
className="col-span-2 mt-1 h-1 overflow-hidden rounded-full bg-white/12"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-ink-300"
|
className="h-full rounded-full bg-linear-to-r from-cyan-500 to-sky-300"
|
||||||
style={{ width: `${Math.max(4, props.share * 100)}%` }}
|
style={{ width: `${Math.max(4, props.share * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,7 +162,7 @@ function Reach({ model }: { model: ShipModel }) {
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<p aria-label="Reach" className="mt-3 text-xs text-ink-300">
|
<p aria-label="Reach" className="mt-3 text-xs text-ink-300">
|
||||||
<span className="font-display text-ink-400">Reach </span>
|
<span className="font-display tracking-[0.14em] text-ink-400 uppercase">Reach </span>
|
||||||
{parts.join(" · ")}
|
{parts.join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
@ -192,14 +192,14 @@ function DeliveryPicker(props: {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={bay.id}
|
key={bay.id}
|
||||||
className="flex cursor-pointer items-center gap-3 rounded-xl border border-glass-line bg-white/5 p-3 text-sm transition has-checked:border-amber-400 has-checked:bg-amber-500/10 has-checked:shadow-glow-amber"
|
className="flex cursor-pointer items-center gap-3 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm transition has-checked:border-amber-400 has-checked:bg-amber-500/10 has-checked:shadow-glow-amber"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="hq"
|
name="hq"
|
||||||
checked={bayId === bay.id}
|
checked={bayId === bay.id}
|
||||||
onChange={() => setBayId(bay.id)}
|
onChange={() => setBayId(bay.id)}
|
||||||
className="size-4 accent-action"
|
className="size-4 accent-amber-400"
|
||||||
/>
|
/>
|
||||||
{sprite && <Art sprite={sprite} className="size-8" />}
|
{sprite && <Art sprite={sprite} className="size-8" />}
|
||||||
<span className="font-display font-semibold">{locationName(bay.locationId)}</span>
|
<span className="font-display font-semibold">{locationName(bay.locationId)}</span>
|
||||||
|
|
@ -230,7 +230,7 @@ function OwnedUpgraded({ modelId }: { modelId: string }) {
|
||||||
);
|
);
|
||||||
if (upgraded.length === 0) return null;
|
if (upgraded.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<ul aria-label="Your upgraded ships" className="mt-3 space-y-1 text-xs text-ink-100">
|
<ul aria-label="Your upgraded ships" className="mt-3 space-y-1 text-xs text-cyan-200">
|
||||||
{upgraded.map((ship) => {
|
{upgraded.map((ship) => {
|
||||||
const s = shipStatsOf(ship);
|
const s = shipStatsOf(ship);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { Link } from "react-router";
|
|
||||||
import { Icon, Screen, ScreenHeader, type IconName } from "../ui";
|
|
||||||
|
|
||||||
const ROWS: { to: string; title: string; detail: string; icon: IconName }[] = [
|
|
||||||
{ to: "/market", title: "Shipyard", detail: "Compare and buy ships", icon: "market" },
|
|
||||||
{
|
|
||||||
to: "/locations",
|
|
||||||
title: "Places & HQs",
|
|
||||||
detail: "Where you can fly, and your bases",
|
|
||||||
icon: "places",
|
|
||||||
},
|
|
||||||
{ to: "/logbook", title: "Logbook", detail: "Every job and refuel, 7 days", icon: "route" },
|
|
||||||
{ to: "/settings", title: "Settings", detail: "Company, display and save", icon: "gear" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Everything that isn't one of the main tabs (decision 0.5.167). */
|
|
||||||
export function More() {
|
|
||||||
return (
|
|
||||||
<Screen>
|
|
||||||
<ScreenHeader title="More" />
|
|
||||||
<ul className="divide-y divide-glass-line">
|
|
||||||
{ROWS.map((row) => (
|
|
||||||
<li key={row.to}>
|
|
||||||
<Link to={row.to} className="flex min-h-15 items-center gap-3 py-2">
|
|
||||||
<Icon name={row.icon} className="size-5 shrink-0 text-ink-300" />
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block font-medium">{row.title}</span>
|
|
||||||
<span className="block text-[13px] text-ink-300">{row.detail}</span>
|
|
||||||
</span>
|
|
||||||
<Icon name="arrow" className="size-4 shrink-0 text-ink-400" />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { FleetViews } from "../components/FleetViews";
|
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { PilotIndependence } from "../components/IndependentPilots";
|
import { PilotIndependence } from "../components/IndependentPilots";
|
||||||
import type { Pilot } from "@deadhead/schema";
|
import type { Pilot } from "@deadhead/schema";
|
||||||
|
|
@ -29,16 +28,15 @@ export function levelProgress(xp: number, level: number) {
|
||||||
export function Pilots() {
|
export function Pilots() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-4xl">
|
||||||
<ScreenHeader title="Crew" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
|
<ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
|
||||||
<FleetViews />
|
|
||||||
<HirePilot />
|
<HirePilot />
|
||||||
{!state.pilots.some((p) => !p.isAvatar) && (
|
{!state.pilots.some((p) => !p.isAvatar) && (
|
||||||
<p className="text-sm text-ink-400">
|
<p className="text-sm text-ink-400">
|
||||||
No hired pilots yet. Hire one at a bay to crew another ship.
|
No hired pilots yet. Hire one at a bay to crew another ship.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
{state.pilots.map((pilot) => (
|
{state.pilots.map((pilot) => (
|
||||||
<PilotCard key={pilot.id} pilot={pilot} />
|
<PilotCard key={pilot.id} pilot={pilot} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -55,18 +53,23 @@ function HirePilot() {
|
||||||
const hasBay = state.bays.some((b) => b.locationId === bay);
|
const hasBay = state.bays.some((b) => b.locationId === bay);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="text-sm">
|
<Panel feature tone="amber" className="text-sm">
|
||||||
{state.bays.length === 0 ? (
|
{state.bays.length === 0 ? (
|
||||||
<p className="text-ink-300">Lease a bay to hire pilots there.</p>
|
<p className="text-ink-300">Lease a bay to hire pilots there.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/40">
|
||||||
|
<Icon name="hire" className="size-5" />
|
||||||
|
</span>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<span className="font-display text-xs text-ink-300">Hire at</span>
|
<span className="font-display text-xs tracking-wider text-ink-300 uppercase">
|
||||||
|
Hire at
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
aria-label="Hire at"
|
aria-label="Hire at"
|
||||||
value={bay}
|
value={bay}
|
||||||
onChange={(e) => setBay(e.target.value)}
|
onChange={(e) => setBay(e.target.value)}
|
||||||
className="min-h-11 rounded-lg border border-glass-line bg-black/25 px-3 font-display text-sm focus:border-action focus:outline-none"
|
className="min-h-11 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display text-sm focus:border-amber-400 focus:outline-none"
|
||||||
>
|
>
|
||||||
{state.bays.map((b) => (
|
{state.bays.map((b) => (
|
||||||
<option key={b.id} value={b.locationId}>
|
<option key={b.id} value={b.locationId}>
|
||||||
|
|
@ -76,8 +79,6 @@ function HirePilot() {
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={!hasBay || state.company.credits < cost || hire.isPending}
|
disabled={!hasBay || state.company.credits < cost || hire.isPending}
|
||||||
onClick={() => hire.mutate({ locationId: bay })}
|
onClick={() => hire.mutate({ locationId: bay })}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
|
|
@ -113,29 +114,29 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`relative grid size-12 shrink-0 place-items-center rounded-full text-base font-semibold ring-1 ${
|
className={`relative grid size-14 shrink-0 place-items-center rounded-full font-display text-lg font-bold ring-2 ${
|
||||||
pilot.isAvatar ? "text-action ring-action/60" : "text-ink-100 ring-glass-line"
|
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}
|
{initials}
|
||||||
<span className="absolute -right-1 -bottom-1 grid size-5 place-items-center rounded-full bg-space-900 text-xs font-semibold text-ink-50 ring-1 ring-glass-line">
|
<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}
|
{pilot.level}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="truncate text-base font-semibold">
|
<span className="truncate font-display text-lg font-bold tracking-wide">
|
||||||
{pilot.name}
|
{pilot.name}
|
||||||
{pilot.isAvatar && (
|
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
|
||||||
<span className="ml-1 text-xs font-normal text-ink-300">(you)</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-xs whitespace-nowrap text-ink-300">
|
<span className="font-display text-xs tracking-wider whitespace-nowrap text-ink-300 uppercase">
|
||||||
Level {pilot.level}
|
Level {pilot.level}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{pilot.isVeteran && (
|
{pilot.isVeteran && (
|
||||||
<p aria-label={`${pilot.name} bio`} className="mt-0.5 text-xs text-ink-300 italic">
|
<p aria-label={`${pilot.name} bio`} className="mt-0.5 text-xs text-amber-200/90 italic">
|
||||||
{content.veteranPilot.bio}
|
{content.veteranPilot.bio}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -144,9 +145,9 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
? `${progress.xp} / ${progress.needed} XP to next level`
|
? `${progress.xp} / ${progress.needed} XP to next level`
|
||||||
: `${pilot.xp} XP · max level`}
|
: `${pilot.xp} XP · max level`}
|
||||||
</div>
|
</div>
|
||||||
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-black/25">
|
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-ink-300"
|
className="h-full rounded-full bg-linear-to-r from-violet-500 to-fuchsia-300"
|
||||||
style={{ width: `${progress ? (progress.xp / progress.needed) * 100 : 100}%` }}
|
style={{ width: `${progress ? (progress.xp / progress.needed) * 100 : 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -169,28 +170,28 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
: "No ship"}
|
: "No ship"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
|
||||||
<div
|
<div
|
||||||
className={`text-xs font-semibold ${pilot.skillPointsAvailable > 0 ? "text-action" : "text-ink-300"}`}
|
className={`font-display text-[11px] tracking-[0.14em] uppercase ${pilot.skillPointsAvailable > 0 ? "text-amber-300" : "text-ink-400"}`}
|
||||||
>
|
>
|
||||||
Skill points:{" "}
|
Skill points:{" "}
|
||||||
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
|
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-1 divide-y divide-glass-line">
|
<ul className="mt-2 grid grid-cols-2 gap-x-3 gap-y-2">
|
||||||
{content.skills.map((skill) => {
|
{content.skills.map((skill) => {
|
||||||
const level = pilot.skills[skill.id] ?? 0;
|
const level = pilot.skills[skill.id] ?? 0;
|
||||||
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
|
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
|
||||||
return (
|
return (
|
||||||
<li key={skill.id} className="flex min-h-11 items-center justify-between gap-2 py-1">
|
<li key={skill.id} className="flex items-center justify-between gap-1">
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-[13px] text-ink-100">
|
<span className="block truncate text-xs text-ink-200">
|
||||||
{skill.name} {level}/{skill.maxLevel}
|
{skill.name} {level}/{skill.maxLevel}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden="true" className="mt-0.5 flex gap-0.5">
|
<span aria-hidden="true" className="mt-0.5 flex gap-0.5">
|
||||||
{Array.from({ length: skill.maxLevel }, (_, i) => (
|
{Array.from({ length: skill.maxLevel }, (_, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-1 w-3 rounded-full ${i < level ? "bg-ink-200" : "bg-white/14"}`}
|
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>
|
||||||
|
|
@ -201,7 +202,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
aria-label={`Raise ${skill.name} for ${pilot.name}`}
|
aria-label={`Raise ${skill.name} for ${pilot.name}`}
|
||||||
disabled={spend.isPending}
|
disabled={spend.isPending}
|
||||||
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
|
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
|
||||||
className="grid size-11 shrink-0 place-items-center rounded-xl border border-action/60 text-action transition hover:bg-action/10 active:scale-95"
|
className="grid size-8 shrink-0 animate-glow place-items-center rounded-lg border border-amber-400/70 bg-amber-500/15 text-amber-200 transition active:scale-95"
|
||||||
>
|
>
|
||||||
<Icon name="plus" className="size-4" />
|
<Icon name="plus" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -243,7 +244,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
|
||||||
if (pilot.restingInShopUntil !== null) {
|
if (pilot.restingInShopUntil !== null) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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-ink-100"
|
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`}
|
aria-label={`${pilot.name} rest`}
|
||||||
>
|
>
|
||||||
<Icon name="rest" className="size-4 shrink-0" />
|
<Icon name="rest" className="size-4 shrink-0" />
|
||||||
|
|
@ -254,7 +255,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
|
||||||
if (pilot.resting && pilot.restEndsAt !== null) {
|
if (pilot.resting && pilot.restEndsAt !== null) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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-ink-100"
|
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`}
|
aria-label={`${pilot.name} rest`}
|
||||||
>
|
>
|
||||||
<Icon name="rest" className="size-4 shrink-0" />
|
<Icon name="rest" className="size-4 shrink-0" />
|
||||||
|
|
@ -267,7 +268,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
|
||||||
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
|
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="flex items-baseline justify-between font-display text-xs text-ink-400">
|
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
<span>Duty</span>
|
<span>Duty</span>
|
||||||
<span className="tracking-normal normal-case">
|
<span className="tracking-normal normal-case">
|
||||||
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
|
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
|
||||||
|
|
@ -277,7 +278,7 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
|
||||||
label={`${pilot.name} duty`}
|
label={`${pilot.name} duty`}
|
||||||
value={used}
|
value={used}
|
||||||
max={pilot.dutyLimit}
|
max={pilot.dutyLimit}
|
||||||
tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "default"}
|
tone={used / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
|
|
@ -344,7 +345,7 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
|
||||||
aria-label={`Assign ${pilot.name} to`}
|
aria-label={`Assign ${pilot.name} to`}
|
||||||
value={shipId}
|
value={shipId}
|
||||||
onChange={(e) => setShipId(e.target.value)}
|
onChange={(e) => setShipId(e.target.value)}
|
||||||
className="min-w-0 flex-1 min-h-11 rounded-lg border border-glass-line bg-black/25 px-3 font-display text-sm focus:border-action focus:outline-none"
|
className="min-w-0 flex-1 min-h-11 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display text-sm focus:border-amber-400 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">Choose a ship…</option>
|
<option value="">Choose a ship…</option>
|
||||||
{ships.map((s) => (
|
{ships.map((s) => (
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ async function shareFile(file: File): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeBox =
|
const codeBox =
|
||||||
"mt-1 h-28 w-full resize-none rounded-lg border border-glass-line bg-black/25 px-3 py-2 font-mono text-xs break-all text-ink-100 focus:border-action focus:outline-none";
|
"mt-1 h-28 w-full resize-none rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 font-mono text-xs break-all text-ink-100 focus:border-amber-400 focus:outline-none";
|
||||||
|
|
||||||
/** "Export save" (decision 0.5.92): the whole company as one code, to copy or share as a file. */
|
/** "Export save" (decision 0.5.92): the whole company as one code, to copy or share as a file. */
|
||||||
export function ExportSave() {
|
export function ExportSave() {
|
||||||
|
|
@ -137,7 +137,7 @@ export function ImportSave({ hasCompany }: { hasCompany: boolean }) {
|
||||||
{hasCompany && (
|
{hasCompany && (
|
||||||
<p
|
<p
|
||||||
role="note"
|
role="note"
|
||||||
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-ink-100 ring-1 ring-amber-400/40"
|
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
|
||||||
>
|
>
|
||||||
This replaces the company on this device. Export it first if you want to keep it.
|
This replaces the company on this device. Export it first if you want to keep it.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -187,7 +187,7 @@ export function ImportSave({ hasCompany }: { hasCompany: boolean }) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<p className="text-sm text-ink-100">
|
<p className="text-sm text-amber-200">
|
||||||
{hasCompany ? "Replace your company with this save?" : "Open this company?"}
|
{hasCompany ? "Replace your company with this save?" : "Open this company?"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export function Settings() {
|
||||||
const [howToPlay, setHowToPlay] = useState(false);
|
const [howToPlay, setHowToPlay] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen className="max-w-2xl">
|
||||||
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
|
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
|
||||||
|
|
||||||
<Section title="Company">
|
<Section title="Company">
|
||||||
|
|
@ -118,7 +118,7 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
const id = `settings-${title.toLowerCase()}`;
|
const id = `settings-${title.toLowerCase()}`;
|
||||||
return (
|
return (
|
||||||
<Panel as="section" aria-labelledby={id} className="space-y-3">
|
<Panel as="section" aria-labelledby={id} className="space-y-3">
|
||||||
<h2 id={id} className="font-display text-sm font-bold text-ink-300">
|
<h2 id={id} className="font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -161,11 +161,11 @@ function Choice<T extends string>(props: {
|
||||||
return (
|
return (
|
||||||
<fieldset className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
<fieldset className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
<legend className="float-left text-ink-400">{props.label}</legend>
|
<legend className="float-left text-ink-400">{props.label}</legend>
|
||||||
<div className="flex overflow-hidden rounded-lg border border-glass-line">
|
<div className="flex overflow-hidden rounded-lg border border-space-600">
|
||||||
{props.options.map(([value, text]) => (
|
{props.options.map(([value, text]) => (
|
||||||
<label
|
<label
|
||||||
key={value}
|
key={value}
|
||||||
className="cursor-pointer px-3 py-1.5 has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
|
className="cursor-pointer px-3 py-1.5 has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|
|
||||||
|
|
@ -83,17 +83,16 @@ function HullRepair({ shipId, atHq }: { shipId: string; atHq: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Hull"
|
aria-label="Hull"
|
||||||
className="mt-4 rounded-xl border border-glass-line bg-white/5 p-3 text-sm"
|
className="mt-4 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="font-display font-bold">Hull</span>
|
<span className="font-display font-bold tracking-wide">Hull</span>
|
||||||
<span className="tabular-nums">{Math.round(pct)}%</span>
|
<span className="tabular-nums">{Math.round(pct)}%</span>
|
||||||
</div>
|
</div>
|
||||||
{pct >= 100 ? (
|
{pct >= 100 ? (
|
||||||
<p className="mt-1 text-ink-400">In perfect shape.</p>
|
<p className="mt-1 text-ink-400">In perfect shape.</p>
|
||||||
) : can ? (
|
) : can ? (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
disabled={repair.isPending || state.company.credits < cost}
|
disabled={repair.isPending || state.company.credits < cost}
|
||||||
|
|
@ -144,7 +143,9 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
|
||||||
<HullRepair shipId={ship.id} atHq={atHq} />
|
<HullRepair shipId={ship.id} atHq={atHq} />
|
||||||
{/* Booked from anywhere; the work starts at the HQ (CR-027). */}
|
{/* Booked from anywhere; the work starts at the HQ (CR-027). */}
|
||||||
{state.tutorial === null && <ServiceSection shipId={ship.id} />}
|
{state.tutorial === null && <ServiceSection shipId={ship.id} />}
|
||||||
<h3 className="mt-4 font-display text-sm font-bold text-ink-300">Upgrades</h3>
|
<h3 className="mt-4 font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
|
||||||
|
Upgrades
|
||||||
|
</h3>
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-2 space-y-2">
|
||||||
{content.upgrades.map((upgrade) => {
|
{content.upgrades.map((upgrade) => {
|
||||||
const level = ship.upgrades[upgrade.id] ?? 0;
|
const level = ship.upgrades[upgrade.id] ?? 0;
|
||||||
|
|
@ -172,10 +173,10 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
|
||||||
<li
|
<li
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
aria-label={upgrade.name}
|
aria-label={upgrade.name}
|
||||||
className="rounded-xl border border-glass-line bg-white/5 p-3 text-sm"
|
className="rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="font-display font-bold">{upgrade.name}</span>
|
<span className="font-display font-bold tracking-wide">{upgrade.name}</span>
|
||||||
<span className="text-ink-400">
|
<span className="text-ink-400">
|
||||||
Level {level}/{max}
|
Level {level}/{max}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -204,7 +205,7 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`Start ${upgrade.name} ${check.level}?`}
|
aria-label={`Start ${upgrade.name} ${check.level}?`}
|
||||||
className="mt-2 rounded-lg bg-black/25 p-2"
|
className="mt-2 rounded-lg bg-space-950/70 p-2"
|
||||||
>
|
>
|
||||||
<p className="text-ink-200">
|
<p className="text-ink-200">
|
||||||
{upgrade.name} {check.level} costs {formatCredits(check.cost)}. {ship.name}{" "}
|
{upgrade.name} {check.level} costs {formatCredits(check.cost)}. {ship.name}{" "}
|
||||||
|
|
@ -213,7 +214,6 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={start.isPending}
|
disabled={start.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
|
||||||
|
|
@ -22,23 +22,29 @@ export function StartScreen() {
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden bg-[url(/assets/map-gen/bg.webp)] bg-cover bg-center"
|
|
||||||
>
|
|
||||||
<Art
|
<Art
|
||||||
sprite="body-earth"
|
sprite="sun"
|
||||||
className="absolute -bottom-[82vmin] left-1/2 sm:-bottom-[96vmin] size-[120vmin] max-w-none -translate-x-1/2 drop-shadow-[0_-8px_60px_rgb(90_160_255/0.45)]"
|
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
|
<Art
|
||||||
sprite="ship-clipper"
|
sprite="ship-courier"
|
||||||
className="absolute bottom-[20vmin] left-1/2 size-[22vmin] max-h-32 max-w-32 translate-x-16 sm:bottom-[10vmin] sm:translate-x-40 rotate-[34deg] drop-shadow-[0_8px_14px_rgb(0_0_0/0.8)]"
|
className="absolute top-[22%] left-[12%] size-16 rotate-[60deg] drop-shadow-[0_0_10px_rgb(251_191_36/0.5)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative animate-rise">
|
<div className="relative animate-rise">
|
||||||
<h1 className="font-title text-3xl sm:text-5xl">DEADHEAD</h1>
|
<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">
|
||||||
<p className="mt-3 text-sm font-medium text-ink-200">Space Freight Tycoon</p>
|
DEADHEAD
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 font-display text-sm tracking-[0.45em] text-cyan-300/80 uppercase">
|
||||||
|
Space Freight Tycoon
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="relative max-w-sm animate-rise text-base text-ink-200">
|
<p className="relative max-w-sm animate-rise text-base text-ink-200">
|
||||||
Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
|
Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,10 @@ export function TutorialBoard() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{firstRun && !introDismissed && (
|
{firstRun && !introDismissed && (
|
||||||
<Panel aria-label="Intro" tone="amber" className="flex items-center gap-3 text-sm">
|
<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" />
|
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-display text-base font-bold text-ink-100">
|
<p className="font-display text-base font-bold text-amber-200">
|
||||||
{content.tutorialCompanyName} needs a pilot.
|
{content.tutorialCompanyName} needs a pilot.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
|
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
|
||||||
|
|
@ -68,8 +68,8 @@ export function TutorialBoard() {
|
||||||
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
||||||
</p>
|
</p>
|
||||||
{runInFlight && (
|
{runInFlight && (
|
||||||
<p className="flex items-center gap-2 rounded-lg border border-glass-line bg-white/5 px-3 py-2 text-sm text-ink-100">
|
<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-ink-300" />
|
<Icon name="send" className="size-4 shrink-0 text-cyan-300" />
|
||||||
Your run is in flight. Wait for it to land.
|
Your run is in flight. Wait for it to land.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -81,7 +81,7 @@ export function TutorialBoard() {
|
||||||
{board.data.contracts.length === 0 && (
|
{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-ink-400">No company runs left this period. Check back soon.</p>
|
||||||
)}
|
)}
|
||||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
{board.data.contracts.map((contract, index) => {
|
{board.data.contracts.map((contract, index) => {
|
||||||
// On the first run, suggest the lightest (fastest) contract.
|
// On the first run, suggest the lightest (fastest) contract.
|
||||||
const suggested = firstRun && index === 0;
|
const suggested = firstRun && index === 0;
|
||||||
|
|
@ -105,13 +105,14 @@ export function TutorialBoard() {
|
||||||
key={contract.id}
|
key={contract.id}
|
||||||
data-suggested={suggested || undefined}
|
data-suggested={suggested || undefined}
|
||||||
data-scripted={scripted ? true : undefined}
|
data-scripted={scripted ? true : undefined}
|
||||||
tone={scripted || suggested ? "amber" : "default"}
|
tone={scripted ? "fuchsia" : suggested ? "amber" : "default"}
|
||||||
|
feature={Boolean(scripted) || suggested}
|
||||||
className={`flex flex-col gap-2 ${suggested ? "shadow-glow-amber" : ""}`}
|
className={`flex flex-col gap-2 ${suggested ? "shadow-glow-amber" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="font-display font-bold">
|
<span className="font-display font-bold tracking-wide">
|
||||||
{scripted ? (
|
{scripted ? (
|
||||||
<span className="text-ink-300">{scripted.name}</span>
|
<span className="text-fuchsia-200">{scripted.name}</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
|
|
@ -129,7 +130,7 @@ export function TutorialBoard() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{scripted && (
|
{scripted && (
|
||||||
<p className="text-xs text-ink-300">
|
<p className="text-xs text-fuchsia-200">
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
|
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
|
||||||
double pay, no questions asked
|
double pay, no questions asked
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -139,10 +140,12 @@ export function TutorialBoard() {
|
||||||
destinationId={contract.destinationId}
|
destinationId={contract.destinationId}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<div className="mt-auto flex items-end justify-between gap-2 border-t border-glass-line pt-2">
|
<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">
|
<dl className="grid flex-1 grid-cols-3 gap-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-display text-xs text-ink-400">Pay</dt>
|
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
|
||||||
|
Pay
|
||||||
|
</dt>
|
||||||
<dd
|
<dd
|
||||||
aria-label="Pay"
|
aria-label="Pay"
|
||||||
className="font-display text-base font-bold text-emerald-300 tabular-nums"
|
className="font-display text-base font-bold text-emerald-300 tabular-nums"
|
||||||
|
|
@ -151,16 +154,20 @@ export function TutorialBoard() {
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-display text-xs text-ink-400">Time</dt>
|
<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">
|
<dd aria-label="Trip time" className="font-display text-base tabular-nums">
|
||||||
{formatDuration(tripMs)}
|
{formatDuration(tripMs)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-display text-xs text-ink-400">XP</dt>
|
<dt className="font-display text-[10px] tracking-[0.16em] text-ink-400 uppercase">
|
||||||
|
XP
|
||||||
|
</dt>
|
||||||
<dd
|
<dd
|
||||||
aria-label="XP"
|
aria-label="XP"
|
||||||
className="font-display text-base text-ink-300 tabular-nums"
|
className="font-display text-base text-violet-300 tabular-nums"
|
||||||
>
|
>
|
||||||
+{xp}
|
+{xp}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { content } from "@deadhead/content";
|
|
||||||
import type { Contract, PlayerState } from "@deadhead/schema";
|
|
||||||
import { contractTripGameHours, permanentFailure } from "@deadhead/sim";
|
|
||||||
import { locationName } from "../format";
|
|
||||||
import { jobMoney, shipStatsOf } from "../game";
|
|
||||||
import { pairOptions } from "./DispatchSheet";
|
|
||||||
|
|
||||||
/** What a job row shows (decision 0.5.169). */
|
|
||||||
export interface JobFigures {
|
|
||||||
/** Payout minus fuel minus the pilot's share, as in the dispatch sheet. Null: no ship could. */
|
|
||||||
net: number | null;
|
|
||||||
/** Net per game hour of the job. */
|
|
||||||
perHour: number | null;
|
|
||||||
/** The job's game hours for the ship the figures are for. */
|
|
||||||
hours: number | null;
|
|
||||||
/** No ship can take it now: figures are for the best ship the company owns. */
|
|
||||||
estimate: boolean;
|
|
||||||
/** A ship can take it now (refuelling first counts: the sheet offers it). */
|
|
||||||
ready: boolean;
|
|
||||||
/** Why it can't go now, or what it needs first. */
|
|
||||||
note: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const perHourOf = (net: number, hours: number) => (hours > 0 ? Math.round(net / hours) : null);
|
|
||||||
|
|
||||||
/** The best ship's figures for a job: one that can take it now, else the best the fleet has. */
|
|
||||||
export function jobFigures(state: PlayerState, contract: Contract, now: number): JobFigures {
|
|
||||||
const options = pairOptions(state, contract, now);
|
|
||||||
const able = options.filter((o) => o.problems.length === 0 || o.shortOfFuel);
|
|
||||||
if (able.length > 0) {
|
|
||||||
const best = able.reduce((a, b) => (b.netProfit > a.netProfit ? b : a));
|
|
||||||
const ship = state.ships.find((s) => s.id === best.shipId)!;
|
|
||||||
const hours = contractTripGameHours(shipStatsOf(ship), contract, content);
|
|
||||||
return {
|
|
||||||
net: best.netProfit,
|
|
||||||
perHour: perHourOf(best.netProfit, hours),
|
|
||||||
hours,
|
|
||||||
estimate: false,
|
|
||||||
ready: true,
|
|
||||||
note: able.every((o) => o.shortOfFuel) ? "Refuel first" : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// A problem that time will fix says so; a ship here that could never take it isn't the job's
|
|
||||||
// problem (CR-019: the board only shows jobs the fleet can complete).
|
|
||||||
const waiting = options.find((o) => {
|
|
||||||
const ship = state.ships.find((s) => s.id === o.shipId)!;
|
|
||||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
|
|
||||||
if (!pilot) return true;
|
|
||||||
const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades };
|
|
||||||
return permanentFailure(contract, member, undefined, content) === null;
|
|
||||||
});
|
|
||||||
const note =
|
|
||||||
waiting?.problems[0] ??
|
|
||||||
(options.length > 0
|
|
||||||
? `Needs another ship at ${locationName(contract.originId)}`
|
|
||||||
: `No idle ship at ${locationName(contract.originId)}`);
|
|
||||||
const candidates = state.ships.flatMap((ship) => {
|
|
||||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
|
|
||||||
if (!pilot) return [];
|
|
||||||
const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades };
|
|
||||||
if (permanentFailure(contract, member, undefined, content) !== null) return [];
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
net: jobMoney(ship, pilot, contract).netProfit,
|
|
||||||
hours: contractTripGameHours(shipStatsOf(ship), contract, content),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return { net: null, perHour: null, hours: null, estimate: false, ready: false, note };
|
|
||||||
}
|
|
||||||
const best = candidates.reduce((a, b) => (b.net > a.net ? b : a));
|
|
||||||
return {
|
|
||||||
net: best.net,
|
|
||||||
perHour: perHourOf(best.net, best.hours),
|
|
||||||
hours: best.hours,
|
|
||||||
estimate: true,
|
|
||||||
ready: false,
|
|
||||||
note,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -11,8 +11,6 @@ import type { Camera } from "../map/camera";
|
||||||
export type ClockFormat = "locale" | "12h" | "24h";
|
export type ClockFormat = "locale" | "12h" | "24h";
|
||||||
export type CountdownFormat = "real" | "game" | "both";
|
export type CountdownFormat = "real" | "game" | "both";
|
||||||
|
|
||||||
export type JobSort = "net" | "rate";
|
|
||||||
|
|
||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
clock: ClockFormat;
|
clock: ClockFormat;
|
||||||
countdown: CountdownFormat;
|
countdown: CountdownFormat;
|
||||||
|
|
@ -22,8 +20,6 @@ export interface Preferences {
|
||||||
dismissedTips: string[];
|
dismissedTips: string[];
|
||||||
/** The map view to restore; null for the default view. */
|
/** The map view to restore; null for the default view. */
|
||||||
mapCamera: Camera | null;
|
mapCamera: Camera | null;
|
||||||
/** How the jobs table sorts (decision 0.5.169). */
|
|
||||||
jobSort: JobSort;
|
|
||||||
/** Local notifications on iOS and Android (decision 0.5.91). */
|
/** Local notifications on iOS and Android (decision 0.5.91). */
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -34,7 +30,6 @@ export const DEFAULT_PREFERENCES: Preferences = {
|
||||||
reducedMotion: null,
|
reducedMotion: null,
|
||||||
dismissedTips: [],
|
dismissedTips: [],
|
||||||
mapCamera: null,
|
mapCamera: null,
|
||||||
jobSort: "net",
|
|
||||||
notifications: true,
|
notifications: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -72,7 +67,6 @@ export function loadPreferences(storage: PreferenceStorage | null): Preferences
|
||||||
dismissedTips: Array.isArray(saved.dismissedTips)
|
dismissedTips: Array.isArray(saved.dismissedTips)
|
||||||
? saved.dismissedTips.filter((t): t is string => typeof t === "string")
|
? saved.dismissedTips.filter((t): t is string => typeof t === "string")
|
||||||
: [],
|
: [],
|
||||||
jobSort: saved.jobSort === "rate" ? "rate" : "net",
|
|
||||||
mapCamera:
|
mapCamera:
|
||||||
saved.mapCamera &&
|
saved.mapCamera &&
|
||||||
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
|
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
|
||||||
|
|
@ -117,7 +111,7 @@ export function createPreferencesStore(
|
||||||
): UseBoundStore<StoreApi<PreferencesStore>> {
|
): UseBoundStore<StoreApi<PreferencesStore>> {
|
||||||
return create<PreferencesStore>((setState, getState) => {
|
return create<PreferencesStore>((setState, getState) => {
|
||||||
const persist = () => {
|
const persist = () => {
|
||||||
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, jobSort, notifications } =
|
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, notifications } =
|
||||||
getState();
|
getState();
|
||||||
savePreferences(storage, {
|
savePreferences(storage, {
|
||||||
clock,
|
clock,
|
||||||
|
|
@ -125,7 +119,6 @@ export function createPreferencesStore(
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
dismissedTips,
|
dismissedTips,
|
||||||
mapCamera,
|
mapCamera,
|
||||||
jobSort,
|
|
||||||
notifications,
|
notifications,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,19 @@ export type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "dan
|
||||||
export type ButtonSize = "sm" | "md" | "lg";
|
export type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary: "bg-action text-on-action hover:brightness-110",
|
primary:
|
||||||
secondary: "border border-glass-line bg-white/6 text-ink-50 hover:bg-white/12",
|
"bg-linear-to-b from-amber-300 to-amber-500 text-space-950 shadow-glow-amber hover:from-amber-200 hover:to-amber-400",
|
||||||
ghost: "text-ink-300 hover:bg-white/6 hover:text-ink-50",
|
secondary:
|
||||||
success: "bg-earned text-space-950 hover:brightness-110",
|
"border border-space-500 bg-space-800/80 text-ink-50 hover:border-amber-400/60 hover:bg-space-700",
|
||||||
danger: "border border-problem/60 bg-problem/12 text-problem hover:bg-problem/20",
|
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> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
sm: "min-h-11 px-3 text-sm",
|
sm: "min-h-9 px-3 text-xs",
|
||||||
md: "min-h-12 px-4 text-[15px]",
|
md: "min-h-11 px-4 text-sm",
|
||||||
lg: "min-h-13 px-6 text-base",
|
lg: "min-h-13 px-6 text-base",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -24,7 +27,7 @@ export function buttonClass(
|
||||||
block = false,
|
block = false,
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
"inline-flex items-center justify-center gap-2 rounded-xl text-center leading-tight font-semibold",
|
"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]",
|
"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",
|
"disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none disabled:saturate-50",
|
||||||
VARIANTS[variant],
|
VARIANTS[variant],
|
||||||
|
|
|
||||||
|
|
@ -3,37 +3,72 @@ import type { ElementType, HTMLAttributes, ReactNode } from "react";
|
||||||
export type Tone = "default" | "amber" | "cyan" | "emerald" | "rose" | "fuchsia" | "muted";
|
export type Tone = "default" | "amber" | "cyan" | "emerald" | "rose" | "fuchsia" | "muted";
|
||||||
|
|
||||||
const TONES: Record<Tone, string> = {
|
const TONES: Record<Tone, string> = {
|
||||||
default: "border-glass-line bg-white/5",
|
default: "border-space-600/70 bg-space-900/75",
|
||||||
muted: "border-glass-line bg-transparent",
|
muted: "border-space-700/70 bg-space-900/45",
|
||||||
amber: "border-action/45 bg-action/8",
|
amber: "border-amber-400/45 bg-amber-950/40",
|
||||||
cyan: "border-flight/35 bg-flight/7",
|
cyan: "border-cyan-400/40 bg-cyan-950/40",
|
||||||
emerald: "border-earned/35 bg-earned/7",
|
emerald: "border-emerald-400/40 bg-emerald-950/40",
|
||||||
rose: "border-problem/45 bg-problem/9",
|
rose: "border-rose-500/45 bg-rose-950/40",
|
||||||
fuchsia: "border-fuchsia-400/45 bg-fuchsia-400/8",
|
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> {
|
export interface PanelProps extends HTMLAttributes<HTMLElement> {
|
||||||
as?: ElementType;
|
as?: ElementType;
|
||||||
tone?: Tone;
|
tone?: Tone;
|
||||||
|
/** Corner brackets, for panels that should stand out. */
|
||||||
|
feature?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A card inside a glass panel (decision 0.5.165). */
|
/** A translucent HUD panel (decision 0.5.47). */
|
||||||
export function Panel({
|
export function Panel({
|
||||||
as: Tag = "div",
|
as: Tag = "div",
|
||||||
tone = "default",
|
tone = "default",
|
||||||
|
feature = false,
|
||||||
className = "",
|
className = "",
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: PanelProps) {
|
}: PanelProps) {
|
||||||
return (
|
return (
|
||||||
<Tag className={`relative rounded-2xl border p-4 ${TONES[tone]} ${className}`} {...rest}>
|
<Tag
|
||||||
|
className={`relative rounded-xl border p-4 shadow-panel backdrop-blur-md ${TONES[tone]} ${className}`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{feature && <Brackets tone={tone} />}
|
||||||
{children}
|
{children}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small label above a value or section. */
|
function Brackets({ tone }: { tone: Tone }) {
|
||||||
export function Eyebrow({ children, className = "" }: { children: ReactNode; className?: string }) {
|
const c = `pointer-events-none absolute size-3 ${BRACKETS[tone]}`;
|
||||||
return <div className={`text-xs font-semibold text-ink-400 ${className}`}>{children}</div>;
|
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,9 +1,9 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
/** A screen's content inside its panel (decision 0.5.166): padded, and rising in on open. */
|
/** A scrolling screen's content column: centred, padded, and rising in on open. */
|
||||||
export function Screen({ children, className = "" }: { children: ReactNode; className?: string }) {
|
export function Screen({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<section className={`w-full animate-rise space-y-4 px-4 pt-1 pb-5 md:pt-4 ${className}`}>
|
<section className={`mx-auto w-full max-w-3xl animate-rise space-y-4 p-4 md:p-6 ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ export function Sheet({
|
||||||
subtitle,
|
subtitle,
|
||||||
tone = "default",
|
tone = "default",
|
||||||
layer = "z-20",
|
layer = "z-20",
|
||||||
side = false,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
titleId: string;
|
titleId: string;
|
||||||
|
|
@ -37,28 +36,30 @@ export function Sheet({
|
||||||
tone?: Tone;
|
tone?: Tone;
|
||||||
/** Stacking layer, e.g. `z-30` for a sheet over other sheets. */
|
/** Stacking layer, e.g. `z-30` for a sheet over other sheets. */
|
||||||
layer?: string;
|
layer?: string;
|
||||||
/** From `xl`, opens beside the wide panel and leaves it usable (decision 0.5.169). */
|
|
||||||
side?: boolean;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-x-0 top-14 bottom-0 ${layer} flex items-end justify-center bg-space-950/70 sm:items-center sm:p-4 ${side ? "xl:pointer-events-none xl:top-20 xl:items-start xl:justify-end xl:bg-transparent xl:pr-[calc(45rem+2rem)]" : ""}`}
|
className={`fixed inset-x-0 top-14 bottom-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
className={`relative max-h-full w-full max-w-md animate-sheet ${side ? "xl:pointer-events-auto xl:max-w-[26rem]" : ""} overflow-y-auto rounded-t-3xl border bg-space-850 p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-3xl ${BORDER_TONES[tone] ?? "border-glass-line"}`}
|
className={`relative max-h-full 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
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="mx-auto -mt-2 mb-3 h-1 w-10 rounded-full bg-white/25 sm:hidden"
|
className="mx-auto -mt-2 mb-3 h-1 w-10 rounded-full bg-space-600 sm:hidden"
|
||||||
/>
|
/>
|
||||||
{eyebrow && <div className="text-xs font-semibold text-ink-400">{eyebrow}</div>}
|
{eyebrow && (
|
||||||
|
<div className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase">
|
||||||
|
{eyebrow}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h2
|
<h2
|
||||||
id={titleId}
|
id={titleId}
|
||||||
className={`font-title text-lg leading-snug ${TITLE_TONES[tone] ?? "text-ink-50"}`}
|
className={`font-display text-xl font-bold tracking-wide ${TITLE_TONES[tone] ?? "text-ink-50"}`}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import { Icon, type IconName } from "./icons";
|
||||||
import type { Tone } from "./Panel";
|
import type { Tone } from "./Panel";
|
||||||
|
|
||||||
const PILL_TONES: Record<Tone, string> = {
|
const PILL_TONES: Record<Tone, string> = {
|
||||||
default: "border-glass-line bg-white/6 text-ink-200",
|
default: "border-space-500 bg-space-800 text-ink-200",
|
||||||
muted: "border-glass-line bg-transparent text-ink-400",
|
muted: "border-space-600 bg-space-900 text-ink-400",
|
||||||
amber: "border-action/50 bg-action/12 text-action",
|
amber: "border-amber-400/50 bg-amber-500/15 text-amber-200",
|
||||||
cyan: "border-flight/45 bg-flight/12 text-flight",
|
cyan: "border-cyan-400/50 bg-cyan-500/15 text-cyan-200",
|
||||||
emerald: "border-earned/45 bg-earned/12 text-earned",
|
emerald: "border-emerald-400/50 bg-emerald-500/15 text-emerald-200",
|
||||||
rose: "border-problem/50 bg-problem/12 text-problem",
|
rose: "border-rose-400/50 bg-rose-500/15 text-rose-200",
|
||||||
fuchsia: "border-fuchsia-400/50 bg-fuchsia-500/15 text-fuchsia-200",
|
fuchsia: "border-fuchsia-400/50 bg-fuchsia-500/15 text-fuchsia-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function Pill({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 rounded-lg border px-2 py-0.5 text-xs font-semibold whitespace-nowrap ${PILL_TONES[tone]} ${className}`}
|
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" />}
|
{icon && <Icon name={icon} className="size-3" />}
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -36,12 +36,11 @@ export function Pill({
|
||||||
}
|
}
|
||||||
|
|
||||||
const METER_FILLS: Partial<Record<Tone, string>> = {
|
const METER_FILLS: Partial<Record<Tone, string>> = {
|
||||||
amber: "bg-action",
|
amber: "from-amber-500 to-amber-300 shadow-[0_0_10px] shadow-amber-400/60",
|
||||||
cyan: "bg-flight",
|
cyan: "from-cyan-500 to-sky-300 shadow-[0_0_10px] shadow-cyan-400/60",
|
||||||
emerald: "bg-earned",
|
emerald: "from-emerald-500 to-emerald-300 shadow-[0_0_10px] shadow-emerald-400/60",
|
||||||
rose: "bg-problem",
|
rose: "from-rose-600 to-rose-400 shadow-[0_0_10px] shadow-rose-500/60",
|
||||||
fuchsia: "bg-fuchsia-300",
|
fuchsia: "from-fuchsia-500 to-fuchsia-300",
|
||||||
default: "bg-ink-300",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A labelled `progressbar`. `label` is its accessible name. */
|
/** A labelled `progressbar`. `label` is its accessible name. */
|
||||||
|
|
@ -70,10 +69,10 @@ export function Meter({
|
||||||
aria-valuemin={min}
|
aria-valuemin={min}
|
||||||
aria-valuemax={max}
|
aria-valuemax={max}
|
||||||
aria-valuenow={value}
|
aria-valuenow={value}
|
||||||
className={`overflow-hidden rounded-full bg-white/12 ${size === "sm" ? "h-1" : "h-1.5"} ${className}`}
|
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
|
<div
|
||||||
className={`h-full rounded-full transition-[width] duration-700 ${METER_FILLS[tone] ?? METER_FILLS.cyan}`}
|
className={`h-full rounded-full bg-linear-to-r transition-[width] duration-700 ${METER_FILLS[tone] ?? METER_FILLS.cyan}`}
|
||||||
style={{ width: `${percent}%` }}
|
style={{ width: `${percent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,14 +104,16 @@ export function StatTile({
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-2xl border border-glass-line bg-white/5 px-3 py-2.5 ${className}`}>
|
<div
|
||||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink-400">
|
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" />}
|
{icon && <Icon name={icon} className="size-3.5" />}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
aria-label={valueLabel}
|
aria-label={valueLabel}
|
||||||
className={`mt-1 text-lg font-bold tabular-nums ${STAT_VALUE_TONES[tone] ?? "text-ink-50"}`}
|
className={`mt-1 font-display text-lg font-bold tabular-nums ${STAT_VALUE_TONES[tone] ?? "text-ink-50"}`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,10 +138,20 @@ export function ScreenHeader({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end justify-between gap-3">
|
<div className="flex items-end justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
{icon && <Icon name={icon} className="size-6 shrink-0 text-ink-300" />}
|
{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">
|
<div className="min-w-0">
|
||||||
{eyebrow && <div className="text-xs font-semibold text-ink-400">{eyebrow}</div>}
|
{eyebrow && (
|
||||||
<h1 className="font-title text-xl leading-tight">{title}</h1>
|
<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>}
|
{subtitle && <p className="text-sm text-ink-300">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,6 @@ const PATHS = {
|
||||||
<path d="M12 2v3.5M12 18.5V22M2 12h3.5M18.5 12H22M4.9 4.9l2.5 2.5M16.6 16.6l2.5 2.5M4.9 19.1l2.5-2.5M16.6 7.4l2.5-2.5" />
|
<path d="M12 2v3.5M12 18.5V22M2 12h3.5M18.5 12H22M4.9 4.9l2.5 2.5M16.6 16.6l2.5 2.5M4.9 19.1l2.5-2.5M16.6 7.4l2.5-2.5" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
// Three dots.
|
|
||||||
more: (
|
|
||||||
<>
|
|
||||||
<circle cx="5" cy="12" r="1.2" />
|
|
||||||
<circle cx="12" cy="12" r="1.2" />
|
|
||||||
<circle cx="19" cy="12" r="1.2" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
// A cog (outline after Lucide "settings", ISC licence).
|
// A cog (outline after Lucide "settings", ISC licence).
|
||||||
gear: (
|
gear: (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ describe("navigation attention badges (Step 10E.3)", () => {
|
||||||
],
|
],
|
||||||
} as Partial<PlayerState>);
|
} as Partial<PlayerState>);
|
||||||
// Skill points: 2 + 0 + 1 across the three pilots (decision 0.5.109).
|
// Skill points: 2 + 0 + 1 across the three pilots (decision 0.5.109).
|
||||||
expect(tabAttention(state)).toEqual({ "/": 2, "/contracts": 1, "/fleet": 3 });
|
expect(tabAttention(state)).toEqual({ "/": 2, "/contracts": 1, "/pilots": 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("nudges toward Jobs during the tutorial until a run is in flight", () => {
|
it("nudges toward Jobs during the tutorial until a run is in flight", () => {
|
||||||
|
|
@ -67,7 +67,7 @@ describe("navigation attention badges (Step 10E.3)", () => {
|
||||||
const nav = await screen.findByRole("navigation", { name: "Main" });
|
const nav = await screen.findByRole("navigation", { name: "Main" });
|
||||||
const jobs = within(nav).getByRole("link", { name: "Jobs" });
|
const jobs = within(nav).getByRole("link", { name: "Jobs" });
|
||||||
expect(within(jobs).getByTestId("badge-Jobs")).toHaveTextContent("1");
|
expect(within(jobs).getByTestId("badge-Jobs")).toHaveTextContent("1");
|
||||||
expect(within(nav).queryByTestId("badge-Fleet")).not.toBeInTheDocument();
|
expect(within(nav).queryByTestId("badge-Pilots")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -99,16 +99,16 @@ describe("Pilots tab badge (Step 17.5, decision 0.5.109)", () => {
|
||||||
});
|
});
|
||||||
renderApp(api, "/pilots");
|
renderApp(api, "/pilots");
|
||||||
const nav = await screen.findByRole("navigation", { name: "Main" });
|
const nav = await screen.findByRole("navigation", { name: "Main" });
|
||||||
expect(await within(nav).findByTestId("badge-Fleet")).toHaveTextContent("3");
|
expect(await within(nav).findByTestId("badge-Pilots")).toHaveTextContent("3");
|
||||||
|
|
||||||
const raise = async (pilot: string) =>
|
const raise = async (pilot: string) =>
|
||||||
userEvent.click(
|
userEvent.click(
|
||||||
(await screen.findAllByRole("button", { name: new RegExp(`for ${pilot}$`) }))[0]!,
|
(await screen.findAllByRole("button", { name: new RegExp(`for ${pilot}$`) }))[0]!,
|
||||||
);
|
);
|
||||||
await raise(avatar.name);
|
await raise(avatar.name);
|
||||||
await waitFor(() => expect(within(nav).getByTestId("badge-Fleet")).toHaveTextContent("2"));
|
await waitFor(() => expect(within(nav).getByTestId("badge-Pilots")).toHaveTextContent("2"));
|
||||||
await raise(avatar.name);
|
await raise(avatar.name);
|
||||||
await raise("Rosa Vance");
|
await raise("Rosa Vance");
|
||||||
await waitFor(() => expect(within(nav).queryByTestId("badge-Fleet")).not.toBeInTheDocument());
|
await waitFor(() => expect(within(nav).queryByTestId("badge-Pilots")).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => {
|
||||||
|
|
||||||
it("does not show the dialog when there is nothing to report", async () => {
|
it("does not show the dialog when there is nothing to report", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
|
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ describe("board cards and the empty board (Step 19.4, decision 0.5.114)", () =>
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
for (const payout of ["9,001 cr", "9,002 cr", "9,003 cr"]) {
|
for (const payout of ["9,001 cr", "9,002 cr", "9,003 cr"]) {
|
||||||
const card = (await screen.findByText(payout)).closest("tr")!;
|
const card = (await screen.findByText(payout)).closest("li")!;
|
||||||
expect(card).not.toHaveTextContent(/too heavy/i);
|
expect(card).not.toHaveTextContent(/too heavy/i);
|
||||||
expect(card).not.toHaveTextContent(/too slow/i);
|
expect(card).not.toHaveTextContent(/too slow/i);
|
||||||
expect(card).not.toHaveTextContent(/skill too low/i);
|
expect(card).not.toHaveTextContent(/skill too low/i);
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
|
||||||
|
|
||||||
it("counts down the player's first look, then disappears", async () => {
|
it("counts down the player's first look, then disappears", async () => {
|
||||||
show(owner([pilot("p1", "Ada")]), [job()]);
|
show(owner([pilot("p1", "Ada")]), [job()]);
|
||||||
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
|
const card = (await screen.findByText("1,234 cr")).closest("li")!;
|
||||||
expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31");
|
expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|
@ -103,13 +103,13 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
|
||||||
|
|
||||||
it("isn't shown without Independent pilots", async () => {
|
it("isn't shown without Independent pilots", async () => {
|
||||||
show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]);
|
show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]);
|
||||||
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
|
const card = (await screen.findByText("1,234 cr")).closest("li")!;
|
||||||
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
|
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isn't shown on a first contact, which is always the player's", async () => {
|
it("isn't shown on a first contact, which is always the player's", async () => {
|
||||||
show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]);
|
show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]);
|
||||||
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
|
const card = (await screen.findByText("1,234 cr")).closest("li")!;
|
||||||
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
|
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ describe("company settings (Step 10K.7)", () => {
|
||||||
|
|
||||||
await userEvent.click(confirm);
|
await userEvent.click(confirm);
|
||||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith("newCompany", {}));
|
await waitFor(() => expect(api.call).toHaveBeenCalledWith("newCompany", {}));
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Jobs" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
|
||||||
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
|
expect(await screen.findByLabelText("Intro")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -49,16 +49,15 @@ function render(contracts: Contract[], ships: Ship[]) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const card = async (payout: string) => (await screen.findByText(payout)).closest("tr")!;
|
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
|
||||||
|
|
||||||
describe("contract card (CR-007)", () => {
|
describe("contract card (CR-007)", () => {
|
||||||
it("shows the good, its class and tier, the shipper, mass and requirement", async () => {
|
it("shows the good, its class and tier, the shipper, mass and requirement", async () => {
|
||||||
render([job({})], [ship("courier")]);
|
render([job({})], [ship("courier")]);
|
||||||
const c = await card("3,210 cr");
|
const c = await card("3,210 cr");
|
||||||
expect(within(c).getByText("Medical Supplies")).toBeInTheDocument();
|
expect(within(c).getByRole("heading", { name: "Medical Supplies" })).toBeInTheDocument();
|
||||||
expect(c).toHaveTextContent("High-Value · T1");
|
expect(c).toHaveTextContent("High-Value · T1");
|
||||||
expect(c).toHaveTextContent("Meridian Health");
|
expect(c).toHaveTextContent("Meridian Health · 6 t");
|
||||||
expect(c).toHaveTextContent("6 t");
|
|
||||||
expect(c).toHaveTextContent("Needs Expensive Cargo 1");
|
expect(c).toHaveTextContent("Needs Expensive Cargo 1");
|
||||||
expect(c).not.toHaveTextContent("Priority");
|
expect(c).not.toHaveTextContent("Priority");
|
||||||
});
|
});
|
||||||
|
|
@ -77,7 +76,7 @@ describe("contract card (CR-007)", () => {
|
||||||
);
|
);
|
||||||
const c = await card("3,210 cr");
|
const c = await card("3,210 cr");
|
||||||
// A Priority good can have its own name.
|
// A Priority good can have its own name.
|
||||||
expect(within(c).getByText("Emergency Parts")).toBeInTheDocument();
|
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
|
||||||
// The window follows the countdown preference: real time by default (CR-011).
|
// The window follows the countdown preference: real time by default (CR-011).
|
||||||
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
|
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
|
||||||
expect(c).not.toHaveTextContent("Too slow");
|
expect(c).not.toHaveTextContent("Too slow");
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,13 @@ describe("Dashboard (CR-020)", () => {
|
||||||
activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
|
activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
|
||||||
});
|
});
|
||||||
renderApp(fakeApi({ getPlayerState: () => state }));
|
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
|
|
||||||
expect(screen.getByLabelText("Ships in flight")).toHaveTextContent("2 flying · 1 idle");
|
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
|
||||||
|
expect(statValue("Ships idle")).toHaveTextContent("1");
|
||||||
|
expect(statValue("Ships in flight")).toHaveTextContent("2");
|
||||||
|
|
||||||
const jobs = screen.getByRole("region", { name: "In flight" });
|
const jobs = screen.getByRole("region", { name: "Ships on jobs" });
|
||||||
const rows = within(jobs).getAllByRole("listitem");
|
const rows = within(jobs).getAllByRole("listitem");
|
||||||
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
|
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
|
||||||
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
|
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
|
||||||
|
|
@ -133,7 +135,7 @@ describe("loan on HQ", () => {
|
||||||
|
|
||||||
it("is hidden once the loan is paid off", async () => {
|
it("is hidden once the loan is paid off", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) }));
|
renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) }));
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
|
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -281,7 +283,7 @@ describe("your ship on the Dashboard (CR-020)", () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
|
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
|
||||||
const p = await panel();
|
const p = await panel();
|
||||||
expect(p).toHaveTextContent("You're not flying a ship right now.");
|
expect(p).toHaveTextContent("You're not flying a ship right now.");
|
||||||
expect(within(p).getByRole("link", { name: "Assign yourself in Crew" })).toHaveAttribute(
|
expect(within(p).getByRole("link", { name: "Assign yourself on Pilots" })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
"/pilots",
|
"/pilots",
|
||||||
);
|
);
|
||||||
|
|
@ -295,9 +297,9 @@ describe("your ship on the Dashboard (CR-020)", () => {
|
||||||
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
|
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says so when nothing needs the player", async () => {
|
it("has an Alerts panel, empty for now", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
|
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
|
||||||
const list = await screen.findByRole("region", { name: "Needs you" });
|
const alerts = await screen.findByRole("region", { name: "Alerts" });
|
||||||
expect(list).toHaveTextContent("Nothing right now.");
|
expect(alerts).toHaveTextContent("No alerts.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ describe("the deal", () => {
|
||||||
}),
|
}),
|
||||||
"/contracts",
|
"/contracts",
|
||||||
);
|
);
|
||||||
await screen.findByRole("heading", { level: 1, name: "Jobs" });
|
await screen.findByRole("heading", { level: 1, name: "Contracts" });
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
|
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
@ -154,7 +154,7 @@ describe("the deal", () => {
|
||||||
);
|
);
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
|
||||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
|
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
|
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ describe("display options in the app (Step 10K.3)", () => {
|
||||||
}),
|
}),
|
||||||
"/contracts?location=earth",
|
"/contracts?location=earth",
|
||||||
);
|
);
|
||||||
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
|
const card = (await screen.findByText("1,234 cr")).closest("li")!;
|
||||||
expect(card).toHaveTextContent("expires in 4m 16s");
|
expect(card).toHaveTextContent("expires in 4m 16s");
|
||||||
act(() => usePreferences.getState().set({ countdown: "both" }));
|
act(() => usePreferences.getState().set({ countdown: "both" }));
|
||||||
expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h");
|
expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h");
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
|
||||||
|
|
||||||
it("shows nothing on the Bridge while awake", async () => {
|
it("shows nothing on the Bridge while awake", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => owner(false) }), "/");
|
renderApp(fakeApi({ getPlayerState: () => owner(false) }), "/");
|
||||||
expect(await screen.findByRole("heading", { name: "Bridge" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
|
expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,7 +115,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
|
||||||
pilots: [samplePlayerState().pilots[0]!, { ...rosa, mode: "manual" }],
|
pilots: [samplePlayerState().pilots[0]!, { ...rosa, mode: "manual" }],
|
||||||
});
|
});
|
||||||
renderApp(fakeApi({ getPlayerState: () => manual }), "/");
|
renderApp(fakeApi({ getPlayerState: () => manual }), "/");
|
||||||
expect(await screen.findByRole("heading", { name: "Bridge" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
|
expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ function render(boards: Contract[][], periodMs = 600_000) {
|
||||||
|
|
||||||
const remember = (ids: string[]) =>
|
const remember = (ids: string[]) =>
|
||||||
localStorage.setItem("deadhead:seen-board:earth", JSON.stringify(ids));
|
localStorage.setItem("deadhead:seen-board:earth", JSON.stringify(ids));
|
||||||
const card = async (payout: string) => (await screen.findByText(payout)).closest("tr")!;
|
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
|
||||||
|
|
||||||
describe("visible board refresh (CR-008)", () => {
|
describe("visible board refresh (CR-008)", () => {
|
||||||
it("tags contracts that weren't on the last board seen here, and says so", async () => {
|
it("tags contracts that weren't on the last board seen here, and says so", async () => {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ describe("goals on HQ", () => {
|
||||||
|
|
||||||
it("is not shown during the tutorial", async () => {
|
it("is not shown during the tutorial", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
|
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
expect(screen.queryByRole("region", { name: "Goals" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("region", { name: "Goals" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
||||||
import { contractTripGameHours, effectiveStats } from "@deadhead/sim";
|
import { contractTripGameHours, effectiveStats } from "@deadhead/sim";
|
||||||
import { screen } from "@testing-library/react";
|
import { screen, within } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
import { formatGameHours } from "../src/format";
|
import { formatGameHours } from "../src/format";
|
||||||
|
|
@ -84,10 +84,9 @@ describe("job length on the board", () => {
|
||||||
);
|
);
|
||||||
const hours = formatGameHours(hoursFor("courier", contract));
|
const hours = formatGameHours(hoursFor("courier", contract));
|
||||||
const time = await screen.findByLabelText(`Takes ${hours} of game time`);
|
const time = await screen.findByLabelText(`Takes ${hours} of game time`);
|
||||||
const row = time.closest("tr")!;
|
const line = time.parentElement!;
|
||||||
expect(row).toHaveTextContent("Pacific Orbital Logistics");
|
expect(line).toHaveTextContent(`Pacific Orbital Logistics · 12 t · 0.38M km · ${hours}`);
|
||||||
expect(row).toHaveTextContent("0.38M km");
|
expect(within(line).getByText(/12 t/)).toBeInTheDocument();
|
||||||
expect(time.parentElement).toHaveTextContent(`12 t · ${hours}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("times the job for the fastest ship that could take it, preferring one at the origin", () => {
|
it("times the job for the fastest ship that could take it, preferring one at the origin", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import type { Contract, Ship } from "@deadhead/schema";
|
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import { usePreferences } from "../src/state/preferences";
|
|
||||||
import { pairOptions } from "../src/screens/DispatchSheet";
|
|
||||||
import { jobFigures } from "../src/screens/jobFigures";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
|
||||||
|
|
||||||
const NOW = Date.now();
|
|
||||||
|
|
||||||
const job = (overrides: Partial<Contract>): Contract =>
|
|
||||||
({
|
|
||||||
id: "j1",
|
|
||||||
locationId: "earth",
|
|
||||||
originId: "earth",
|
|
||||||
destinationId: "moon",
|
|
||||||
goodId: "consumer-goods",
|
|
||||||
cargoTypeId: "standard",
|
|
||||||
massTonnes: 10,
|
|
||||||
payout: 2000,
|
|
||||||
shipper: "Pacific Orbital Logistics",
|
|
||||||
offeredAt: NOW,
|
|
||||||
expiresAt: NOW + 3_600_000,
|
|
||||||
priority: false,
|
|
||||||
deliveryWindowGameHours: null,
|
|
||||||
tier: null,
|
|
||||||
requiredSkill: null,
|
|
||||||
requiredLongHaulLevel: 0,
|
|
||||||
firstContact: false,
|
|
||||||
...overrides,
|
|
||||||
}) as Contract;
|
|
||||||
|
|
||||||
const ship = (id: string, overrides: Partial<Ship> = {}): Ship => ({
|
|
||||||
id,
|
|
||||||
modelId: "courier",
|
|
||||||
name: id,
|
|
||||||
status: "idle",
|
|
||||||
locationId: "earth",
|
|
||||||
dispatchId: null,
|
|
||||||
lastArrivalAt: 0,
|
|
||||||
...shipExtras("courier"),
|
|
||||||
pilotId: "avatar",
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
function owner(ships: Ship[]) {
|
|
||||||
const base = samplePlayerState();
|
|
||||||
return samplePlayerState({
|
|
||||||
tutorial: null,
|
|
||||||
bays: [{ id: "b", locationId: "earth" }],
|
|
||||||
ships,
|
|
||||||
pilots: [{ ...base.pilots[0]!, assignedShipId: ships[0]?.id ?? null }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(contracts: Contract[], ships: Ship[]) {
|
|
||||||
renderApp(
|
|
||||||
fakeApi({
|
|
||||||
getPlayerState: () => owner(ships),
|
|
||||||
getContractBoard: () => ({ contracts, periodEndsAt: NOW + 3_600_000 }),
|
|
||||||
}),
|
|
||||||
"/contracts?location=earth",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowNames = () =>
|
|
||||||
screen
|
|
||||||
.getAllByRole("row")
|
|
||||||
.map((r) => r.getAttribute("aria-label"))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
describe("jobs table (decision 0.5.169)", () => {
|
|
||||||
beforeEach(() => usePreferences.setState({ jobSort: "net" }));
|
|
||||||
|
|
||||||
it("shows the same net as the dispatch sheet", () => {
|
|
||||||
const state = owner([ship("s1")]);
|
|
||||||
const contract = job({});
|
|
||||||
const figures = jobFigures(state, contract, NOW);
|
|
||||||
expect(figures.ready).toBe(true);
|
|
||||||
expect(figures.estimate).toBe(false);
|
|
||||||
expect(figures.net).toBe(pairOptions(state, contract, NOW)[0]!.netProfit);
|
|
||||||
expect(figures.perHour).toBe(Math.round(figures.net! / figures.hours!));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sorts by net, then by credits per hour, and remembers the choice", async () => {
|
|
||||||
const big = job({
|
|
||||||
id: "big",
|
|
||||||
goodId: "packaged-food",
|
|
||||||
payout: 3000,
|
|
||||||
destinationId: "moon",
|
|
||||||
massTonnes: 18,
|
|
||||||
});
|
|
||||||
const small = job({ id: "small", goodId: "mail", payout: 2000, massTonnes: 1 });
|
|
||||||
show([small, big], [ship("s1")]);
|
|
||||||
await screen.findByRole("table");
|
|
||||||
expect(rowNames()).toEqual(["Packaged Food", "Mail & Parcels"]);
|
|
||||||
const net = screen.getByRole("columnheader", { name: /Net/ });
|
|
||||||
expect(net).toHaveAttribute("aria-sort", "descending");
|
|
||||||
|
|
||||||
await userEvent.click(within(screen.getByRole("table")).getByRole("button", { name: /cr\/h/ }));
|
|
||||||
expect(screen.getByRole("columnheader", { name: /cr\/h/ })).toHaveAttribute(
|
|
||||||
"aria-sort",
|
|
||||||
"descending",
|
|
||||||
);
|
|
||||||
expect(net).toHaveAttribute("aria-sort", "none");
|
|
||||||
expect(usePreferences.getState().jobSort).toBe("rate");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps jobs it can't take below, with the reason and an estimate", async () => {
|
|
||||||
// The only ship is away: the figures are an estimate, and the row says why.
|
|
||||||
const away = ship("s1", { status: "in-flight", locationId: null, dispatchId: "d" });
|
|
||||||
show([job({})], [away]);
|
|
||||||
await screen.findByRole("table");
|
|
||||||
expect(screen.getByText("Can't take yet")).toBeInTheDocument();
|
|
||||||
const row = screen.getByRole("row", { name: "Consumer Goods" });
|
|
||||||
expect(row).toHaveTextContent("No idle ship at Earth");
|
|
||||||
expect(within(row).getByLabelText("Net, estimated")).toHaveTextContent(/^≈/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens the job from its row", async () => {
|
|
||||||
show([job({})], [ship("s1")]);
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
|
|
||||||
await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -39,48 +39,14 @@ describe("app layout", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens on the Bridge among four tabs (CR-029)", async () => {
|
it("opens on the Bridge tab, the Dashboard, with HQs among eight tabs (CR-020)", async () => {
|
||||||
renderApp(signedIn());
|
renderApp(signedIn());
|
||||||
const nav = await screen.findByRole("navigation", { name: "Main" });
|
const nav = await screen.findByRole("navigation", { name: "Main" });
|
||||||
const links = within(nav).getAllByRole("link");
|
const links = within(nav).getAllByRole("link");
|
||||||
["Bridge", "Jobs", "Fleet", "More"].forEach((name, i) =>
|
expect(links).toHaveLength(8);
|
||||||
expect(links[i]).toHaveAccessibleName(name),
|
expect(links[0]).toHaveAccessibleName("Bridge");
|
||||||
);
|
|
||||||
expect(links[0]).toHaveAttribute("aria-current", "page");
|
expect(links[0]).toHaveAttribute("aria-current", "page");
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
|
||||||
});
|
expect(within(nav).getByRole("link", { name: "HQs" })).toHaveAttribute("href", "/hqs");
|
||||||
|
|
||||||
it.each([
|
|
||||||
["/map", "Map", "Bridge"],
|
|
||||||
["/pilots", "Crew", "Fleet"],
|
|
||||||
["/market", "Shipyard", "More"],
|
|
||||||
["/locations", "Places", "More"],
|
|
||||||
["/hqs", "HQs", "More"],
|
|
||||||
["/logbook", "Logbook", "More"],
|
|
||||||
["/settings", "Settings", "More"],
|
|
||||||
])("still opens %s, as %s under %s", async (path, title, tab) => {
|
|
||||||
renderApp(signedIn(), path);
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
|
||||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
|
||||||
expect(within(nav).getByRole("link", { name: tab })).toHaveAttribute("aria-current", "page");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches between Ships and Crew inside Fleet", async () => {
|
|
||||||
renderApp(signedIn(), "/fleet");
|
|
||||||
const views = await screen.findByRole("navigation", { name: "Fleet views" });
|
|
||||||
await userEvent.click(within(views).getByRole("link", { name: /^Crew/ }));
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Crew" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("More reaches every screen without a tab", async () => {
|
|
||||||
renderApp(signedIn(), "/more");
|
|
||||||
await screen.findByRole("heading", { level: 1, name: "More" });
|
|
||||||
for (const [name, href] of [
|
|
||||||
[/^Shipyard/, "/market"],
|
|
||||||
[/^Places & HQs/, "/locations"],
|
|
||||||
[/^Logbook/, "/logbook"],
|
|
||||||
] as const) {
|
|
||||||
expect(screen.getByRole("link", { name })).toHaveAttribute("href", href);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -156,18 +156,21 @@ describe("location contract boards", () => {
|
||||||
renderApp(boardApi(), "/contracts?location=earth");
|
renderApp(boardApi(), "/contracts?location=earth");
|
||||||
|
|
||||||
await screen.findByText("1,999 cr");
|
await screen.findByText("1,999 cr");
|
||||||
const plain = screen.getByRole("row", { name: "Consumer Goods" });
|
const items = screen.getAllByRole("listitem");
|
||||||
const special = screen.getByRole("row", { name: "Machine Parts" });
|
const [plain, special] = items;
|
||||||
// CR-007: good, class chip, shipper, mass and distance.
|
// CR-007 card: good headline, class chip, shipper · mass · distance.
|
||||||
expect(plain).toHaveTextContent("Pacific Orbital Logistics");
|
expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument();
|
||||||
expect(plain).toHaveTextContent("50 t");
|
expect(plain).toHaveTextContent("Standard");
|
||||||
|
expect(plain).toHaveTextContent("Pacific Orbital Logistics · 50 t");
|
||||||
|
expect(plain).toHaveTextContent("No skills needed");
|
||||||
expect(plain).toHaveTextContent("expires in 30m 00s");
|
expect(plain).toHaveTextContent("expires in 30m 00s");
|
||||||
expect(within(plain).queryByText("First contact")).not.toBeInTheDocument();
|
expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
|
||||||
|
|
||||||
expect(special).toHaveTextContent("To Mars");
|
expect(special).toHaveTextContent("Earth → Mars");
|
||||||
|
expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument();
|
||||||
expect(special).toHaveTextContent("Heavy · T2");
|
expect(special).toHaveTextContent("Heavy · T2");
|
||||||
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
|
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
|
||||||
expect(special).toHaveTextContent(/M km/);
|
expect(special).toHaveTextContent(/M km/);
|
||||||
expect(within(special).getByText("First contact")).toBeInTheDocument();
|
expect(within(special!).getByText("First contact")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,8 @@ describe("the Logbook screen (Step 25.4, decision 0.5.141)", () => {
|
||||||
|
|
||||||
it("is reachable from the Bridge", async () => {
|
it("is reachable from the Bridge", async () => {
|
||||||
renderApp(app(), "/");
|
renderApp(app(), "/");
|
||||||
await userEvent.click(await screen.findByRole("link", { name: "Open the Logbook" }));
|
const card = await screen.findByLabelText("Logbook");
|
||||||
|
await userEvent.click(within(card).getByRole("link", { name: "Open the Logbook" }));
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,10 @@ import { buildScene } from "../src/map/scene";
|
||||||
export function MapCanvas({
|
export function MapCanvas({
|
||||||
state,
|
state,
|
||||||
onSelect,
|
onSelect,
|
||||||
focused = true,
|
|
||||||
}: {
|
}: {
|
||||||
state: PlayerState;
|
state: PlayerState;
|
||||||
onSelect?: (selection: MapSelection | null) => void;
|
onSelect?: (selection: MapSelection | null) => void;
|
||||||
focused?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
// Behind a panel, the screen's own controls are what tests look for.
|
|
||||||
if (!focused) return <div data-testid="map-canvas" />;
|
|
||||||
const scene = buildScene(state, Date.now());
|
const scene = buildScene(state, Date.now());
|
||||||
return (
|
return (
|
||||||
<div data-testid="map-canvas">
|
<div data-testid="map-canvas">
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import type { PlayerState, RadioCall, Ship } from "@deadhead/schema";
|
|
||||||
import { screen, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { needsYou } from "../src/components/NeedsYou";
|
|
||||||
import { tabAttention } from "../src/layout/attention";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
|
||||||
|
|
||||||
const base = samplePlayerState();
|
|
||||||
const avatar = base.pilots[0]!;
|
|
||||||
|
|
||||||
const ship: Ship = {
|
|
||||||
id: "s1",
|
|
||||||
modelId: "courier",
|
|
||||||
name: "Restless Sparrow",
|
|
||||||
status: "idle",
|
|
||||||
locationId: "earth",
|
|
||||||
dispatchId: null,
|
|
||||||
lastArrivalAt: 0,
|
|
||||||
...shipExtras("courier"),
|
|
||||||
pilotId: "avatar",
|
|
||||||
};
|
|
||||||
|
|
||||||
const owner = (overrides: Partial<PlayerState> = {}): PlayerState =>
|
|
||||||
samplePlayerState({
|
|
||||||
tutorial: null,
|
|
||||||
ships: [ship],
|
|
||||||
bays: [{ id: "b", locationId: "earth" }],
|
|
||||||
pilots: [{ ...avatar, assignedShipId: "s1", skillPointsAvailable: 2 }],
|
|
||||||
goals: [{ id: "buy-a-ship", status: "ready" }],
|
|
||||||
...overrides,
|
|
||||||
} as Partial<PlayerState>);
|
|
||||||
|
|
||||||
const call = { dispatchId: "d1", eventId: "debris-field", pilotId: avatar.id } as RadioCall;
|
|
||||||
|
|
||||||
describe("Needs you (decision 0.5.168)", () => {
|
|
||||||
it("orders radio calls, ready ships, skill points, then goals", () => {
|
|
||||||
const items = needsYou(owner({ radioCalls: [{ ...call, windowEndsAt: 1 }] }));
|
|
||||||
expect(items.map((i) => i.kind)).toEqual(["radio", "ready", "skills", "goal"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("badges count what the list holds, tab by tab", () => {
|
|
||||||
const state = owner({ radioCalls: [{ ...call, windowEndsAt: 1 }] });
|
|
||||||
const items = needsYou(state);
|
|
||||||
const badges = tabAttention(state);
|
|
||||||
expect(badges).toEqual({ "/": 2, "/contracts": 1, "/fleet": 2 });
|
|
||||||
const total = items.reduce((sum, i) => sum + i.count, 0);
|
|
||||||
expect(Object.values(badges).reduce((sum, n) => sum + n, 0)).toBe(total);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names who has skill points and opens Crew", async () => {
|
|
||||||
renderApp(fakeApi({ getPlayerState: () => owner() }));
|
|
||||||
const list = await screen.findByRole("region", { name: "Needs you" });
|
|
||||||
await userEvent.click(within(list).getByRole("link", { name: /2 skill points to spend/ }));
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Crew" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends a ready ship to the jobs where it is", async () => {
|
|
||||||
renderApp(
|
|
||||||
fakeApi({
|
|
||||||
getPlayerState: () => owner(),
|
|
||||||
getContractBoard: () => ({ contracts: [], periodEndsAt: Date.now() + 60_000 }) as never,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const list = await screen.findByRole("region", { name: "Needs you" });
|
|
||||||
expect(within(list).getByRole("link", { name: /Restless Sparrow is ready/ })).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/contracts?location=earth",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -24,7 +24,7 @@ describe("guest onboarding", () => {
|
||||||
expect(api.call).toHaveBeenCalledWith("createGuest", {});
|
expect(api.call).toHaveBeenCalledWith("createGuest", {});
|
||||||
|
|
||||||
// Lands on Contracts with the intro and the light contract suggested.
|
// Lands on Contracts with the intro and the light contract suggested.
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Jobs" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
|
||||||
expect(await screen.findByLabelText("Intro")).toHaveTextContent(
|
expect(await screen.findByLabelText("Intro")).toHaveTextContent(
|
||||||
"Halvorsen Lunar Freight needs a pilot.",
|
"Halvorsen Lunar Freight needs a pilot.",
|
||||||
);
|
);
|
||||||
|
|
@ -44,7 +44,7 @@ describe("guest onboarding", () => {
|
||||||
it("skips the start screen when a stored session is still valid", async () => {
|
it("skips the start screen when a stored session is still valid", async () => {
|
||||||
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
|
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
|
||||||
renderApp(api);
|
renderApp(api);
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Bridge" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument();
|
||||||
expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything());
|
expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { DEFAULT_CAMERA, framed, useMapFrame } from "../src/map/camera";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
|
||||||
|
|
||||||
const app = () => fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) });
|
|
||||||
|
|
||||||
describe("the shell (decision 0.5.166)", () => {
|
|
||||||
it("steps the panel through its heights by tap or keyboard, never needing a drag", async () => {
|
|
||||||
renderApp(app());
|
|
||||||
const handle = await screen.findByRole("button", { name: /Panel height: half/ });
|
|
||||||
await userEvent.click(handle);
|
|
||||||
expect(handle).toHaveAccessibleName(/Panel height: full/);
|
|
||||||
await userEvent.click(handle);
|
|
||||||
expect(handle).toHaveAccessibleName(/Panel height: peek/);
|
|
||||||
await userEvent.click(handle);
|
|
||||||
expect(handle).toHaveAccessibleName(/Panel height: half/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens tall screens tall", async () => {
|
|
||||||
renderApp(app(), "/settings");
|
|
||||||
expect(await screen.findByRole("button", { name: /Panel height: full/ })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the map mounted across screens", async () => {
|
|
||||||
renderApp(app());
|
|
||||||
const canvas = await screen.findByTestId("map-canvas");
|
|
||||||
await userEvent.click(screen.getByRole("link", { name: "More" }));
|
|
||||||
await screen.findByRole("heading", { level: 1, name: "More" });
|
|
||||||
expect(screen.getByTestId("map-canvas")).toBe(canvas);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shifts the view by the frame without touching the saved camera", () => {
|
|
||||||
useMapFrame.setState({ offsetX: 10, offsetY: -20 });
|
|
||||||
expect(framed(DEFAULT_CAMERA, useMapFrame.getState())).toEqual({
|
|
||||||
...DEFAULT_CAMERA,
|
|
||||||
panX: 10,
|
|
||||||
panY: -20,
|
|
||||||
});
|
|
||||||
expect(DEFAULT_CAMERA.panX).toBe(0);
|
|
||||||
useMapFrame.setState({ offsetX: 0, offsetY: 0 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -46,7 +46,7 @@ describe("player state store", () => {
|
||||||
getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }),
|
getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }),
|
||||||
});
|
});
|
||||||
renderApp(api);
|
renderApp(api);
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
expect(stateCalls(api)).toBe(1);
|
expect(stateCalls(api)).toBe(1);
|
||||||
|
|
||||||
act(() => vi.advanceTimersByTime(29_000));
|
act(() => vi.advanceTimersByTime(29_000));
|
||||||
|
|
@ -67,7 +67,7 @@ describe("player state store on focus", () => {
|
||||||
it("refetches when the app regains focus", async () => {
|
it("refetches when the app regains focus", async () => {
|
||||||
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
|
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
|
||||||
renderApp(api);
|
renderApp(api);
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
const before = stateCalls(api);
|
const before = stateCalls(api);
|
||||||
|
|
||||||
// Leaving and returning to the app, as the browser reports it to TanStack Query.
|
// Leaving and returning to the app, as the browser reports it to TanStack Query.
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ describe("radio calls (Step 26.6, decision 0.5.144)", () => {
|
||||||
await userEvent.click(within(sheet).getByRole("button", { name: "Decide later" }));
|
await userEvent.click(within(sheet).getByRole("button", { name: "Decide later" }));
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
const panel = screen.getByLabelText("Radio calls");
|
const panel = screen.getByLabelText("Radio calls");
|
||||||
expect(panel).toHaveTextContent("Debris field ahead");
|
expect(panel).toHaveTextContent("Radio calls (1)");
|
||||||
await userEvent.click(within(panel).getByRole("button", { name: "Answer" }));
|
await userEvent.click(within(panel).getByRole("button", { name: "Answer" }));
|
||||||
expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument();
|
expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
|
||||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Shake on it" }));
|
await userEvent.click(screen.getByRole("button", { name: "Shake on it" }));
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
// The deal is done, but the permission prompt is still open.
|
// The deal is done, but the permission prompt is still open.
|
||||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
|
||||||
it("Go to Pilots opens the Pilots tab and the pop-up doesn't come back", async () => {
|
it("Go to Pilots opens the Pilots tab and the pop-up doesn't come back", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => afterDeal() }), "/");
|
renderApp(fakeApi({ getPlayerState: () => afterDeal() }), "/");
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Go to Pilots" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Go to Pilots" }));
|
||||||
await screen.findByRole("heading", { level: 1, name: "Crew" });
|
await screen.findByRole("heading", { level: 1, name: "Pilots" });
|
||||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -100,7 +100,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(),
|
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||||
|
|
||||||
act(() => usePreferences.getState().showTipsAgain());
|
act(() => usePreferences.getState().showTipsAgain());
|
||||||
|
|
@ -109,7 +109,7 @@ describe("skill point pop-up (Step 17.6, decision 0.5.109)", () => {
|
||||||
|
|
||||||
it("doesn't appear with no unspent points", async () => {
|
it("doesn't appear with no unspent points", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 0) }), "/");
|
renderApp(fakeApi({ getPlayerState: () => afterDeal(0, 0) }), "/");
|
||||||
await screen.findByRole("heading", { level: 1, name: "Bridge" });
|
await screen.findByRole("heading", { level: 1, name: "Dashboard" });
|
||||||
// Give the prompt check a turn to settle.
|
// Give the prompt check a turn to settle.
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog", { name: POP_UP })).not.toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -86,13 +86,13 @@ describe("empty states", () => {
|
||||||
|
|
||||||
it("HQ: no ships points to the Market", async () => {
|
it("HQ: no ships points to the Market", async () => {
|
||||||
show("/");
|
show("/");
|
||||||
expect(await screen.findByText("No ships yet — buy one in the Shipyard.")).toBeInTheDocument();
|
expect(await screen.findByText("No ships yet — buy one in the Market.")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Nothing in flight.")).toBeInTheDocument();
|
expect(screen.getByText("Nothing in flight.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Fleet: no ships links to the Market", async () => {
|
it("Fleet: no ships links to the Market", async () => {
|
||||||
show("/fleet");
|
show("/fleet");
|
||||||
expect(await screen.findByRole("link", { name: "visit the Shipyard" })).toHaveAttribute(
|
expect(await screen.findByRole("link", { name: "visit the Market" })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
"/market",
|
"/market",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,86 +3,21 @@ import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../src/index.css"), "utf8");
|
||||||
const css = readFileSync(join(here, "../src/index.css"), "utf8");
|
|
||||||
|
|
||||||
type Rgb = readonly [number, number, number];
|
describe("theme (CR-005)", () => {
|
||||||
|
it("self-hosts the display and body fonts", () => {
|
||||||
/** A `--color-…` token from the theme as RGB, with its alpha if it has one. */
|
expect(css).toMatch(/@import "@fontsource\/chakra-petch\//);
|
||||||
function token(name: string): { rgb: Rgb; alpha: number } {
|
expect(css).toMatch(/@import "@fontsource\/barlow\//);
|
||||||
const value = new RegExp(`--color-${name}: ([^;]+);`).exec(css)?.[1];
|
expect(css).toMatch(/--font-display: "Chakra Petch"/);
|
||||||
if (!value) throw new Error(`no token --color-${name}`);
|
expect(css).toMatch(/--font-sans: "Barlow"/);
|
||||||
if (value.startsWith("#")) {
|
|
||||||
const n = parseInt(value.slice(1), 16);
|
|
||||||
return { rgb: [(n >> 16) & 255, (n >> 8) & 255, n & 255], alpha: 1 };
|
|
||||||
}
|
|
||||||
const [r, g, b, a] = value.match(/[\d.]+/g)!.map(Number);
|
|
||||||
return { rgb: [r!, g!, b!], alpha: a ?? 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const over = (top: Rgb, alpha: number, under: Rgb): Rgb =>
|
|
||||||
[0, 1, 2].map((i) => top[i]! * alpha + under[i]! * (1 - alpha)) as unknown as Rgb;
|
|
||||||
|
|
||||||
function luminance(rgb: Rgb): number {
|
|
||||||
const [r, g, b] = rgb.map((v) => {
|
|
||||||
const c = v / 255;
|
|
||||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
||||||
});
|
|
||||||
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contrast(a: Rgb, b: Rgb): number {
|
|
||||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
||||||
return (hi! + 0.05) / (lo! + 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEXT = [
|
|
||||||
"ink-50",
|
|
||||||
"ink-100",
|
|
||||||
"ink-200",
|
|
||||||
"ink-300",
|
|
||||||
"ink-400",
|
|
||||||
"ink-500",
|
|
||||||
"action",
|
|
||||||
"flight",
|
|
||||||
"earned",
|
|
||||||
"problem",
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("theme (CR-029)", () => {
|
|
||||||
it("self-hosts the title and body fonts", () => {
|
|
||||||
expect(css).toMatch(/@import "@fontsource\/michroma\//);
|
|
||||||
expect(css).toMatch(/@import "@fontsource\/instrument-sans\//);
|
|
||||||
expect(css).toMatch(/--font-title: "Michroma"/);
|
|
||||||
expect(css).toMatch(/--font-sans: "Instrument Sans"/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the wide title face off labels and numbers", () => {
|
it("puts every screen on the space backdrop", () => {
|
||||||
expect(css).toMatch(/--font-display: "Instrument Sans"/);
|
expect(css).toContain('url("/assets/map-gen/bg.webp")');
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the plain ground behind screens, not a backdrop image", () => {
|
|
||||||
expect(css).not.toContain("bg.webp");
|
|
||||||
expect(css).toContain("background-color: var(--color-space-950)");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("turns motion off under prefers-reduced-motion", () => {
|
it("turns motion off under prefers-reduced-motion", () => {
|
||||||
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A panel can sit over the Sun or a bright star, so white is the worst case.
|
|
||||||
it.each(TEXT)("%s reads at 4.5:1 on the glass over dark space and over white", (name) => {
|
|
||||||
const glass = token("glass");
|
|
||||||
const text = token(name).rgb;
|
|
||||||
for (const behind of [token("space-950").rgb, [255, 255, 255] as Rgb]) {
|
|
||||||
expect(contrast(text, over(glass.rgb, glass.alpha, behind))).toBeGreaterThanOrEqual(4.5);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["on-action", "action"],
|
|
||||||
["on-problem", "problem"],
|
|
||||||
])("%s reads at 4.5:1 on %s", (text, fill) => {
|
|
||||||
expect(contrast(token(text).rgb, token(fill).rgb)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ describe("welcome cards (Step 22.2, decision 0.5.131)", () => {
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
expect(api.call).toHaveBeenCalledWith("markWelcomeSeen", {});
|
expect(api.call).toHaveBeenCalledWith("markWelcomeSeen", {});
|
||||||
expect(state().welcomeSeen).toBe(true);
|
expect(state().welcomeSeen).toBe(true);
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Jobs" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
|
||||||
expect(await screen.findByLabelText("Intro")).toHaveTextContent("needs a pilot");
|
expect(await screen.findByLabelText("Intro")).toHaveTextContent("needs a pilot");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { defineConfig, type Plugin } from "vite";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
/** --color-space-900: the app's background, used for the splash and browser chrome. */
|
/** --color-space-900: the app's background, used for the splash and browser chrome. */
|
||||||
const THEME_COLOR = "#050919";
|
const THEME_COLOR = "#080d1d";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installable PWA (Step 11.1): manifest, and a service worker that precaches the
|
* Installable PWA (Step 11.1): manifest, and a service worker that precaches the
|
||||||
|
|
|
||||||
|
|
@ -91,5 +91,3 @@ Record these in `DECISIONS.md` (as plan §0.5.47–0.5.51).
|
||||||
|
|
||||||
- **Step 8.1 (layout):** the header and bottom navigation are restyled per decision 0.5.49; desktop uses a left rail.
|
- **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.
|
- **Step 11.1 (PWA):** the self-hosted font files are part of the offline asset cache.
|
||||||
|
|
||||||
- **Amended by CR-029:** The Deep Field theme and four-tab navigation replace this document's look and eight tabs.
|
|
||||||
|
|
|
||||||
|
|
@ -46,5 +46,3 @@ Record as plan §0.5.118–0.5.121.
|
||||||
|
|
||||||
### Step 20.4 — Regression and review
|
### Step 20.4 — Regression and review
|
||||||
- Plan amendments (Steps 8.1 and 10E.4), full suite, coverage, lint, build, screenshots of Dashboard and HQs at desktop and phone width. Stop for review.
|
- Plan amendments (Steps 8.1 and 10E.4), full suite, coverage, lint, build, screenshots of Dashboard and HQs at desktop and phone width. Stop for review.
|
||||||
|
|
||||||
- **Amended by CR-029:** The Bridge leads with the player's ship and a Needs you list; the Alerts panel and stat tiles are gone, and the map sits behind it.
|
|
||||||
|
|
|
||||||
|
|
@ -104,5 +104,3 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the nex
|
||||||
## 7. Out of scope
|
## 7. Out of scope
|
||||||
|
|
||||||
A notification history panel beyond the Logbook, per-type toast settings, sounds, browser (system) notifications on web, and anything else in `implementation-plan.md` §0.3.
|
A notification history panel beyond the Logbook, per-type toast settings, sounds, browser (system) notifications on web, and anything else in `implementation-plan.md` §0.3.
|
||||||
|
|
||||||
- **Amended by CR-029:** Toasts follow the new theme; their rules are unchanged.
|
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
# Change Request CR-029 — Deep Field UI Rework
|
|
||||||
|
|
||||||
**Project:** Deadhead: Space Freight Tycoon
|
|
||||||
**Applies to:** a build with Phase 13 and CR-001 through CR-028 complete
|
|
||||||
**Do this:** as **Phase 29**
|
|
||||||
**Status:** built (Phase 29); see the notes in `DECISIONS.md` for where the build differs from this plan
|
|
||||||
**Related docs:** `CR-005-ui-overhaul.md`, `CR-020-bridge-dashboard.md`, `CR-028-live-toasts.md`, `DECISIONS.md`
|
|
||||||
**Design reference:** the Deep Field canvas, https://claude.ai/artifact/LprS2DkuTSf2PfJm7VUSe4 (phone prototype, all 15 phone screens, 15 desktop views)
|
|
||||||
|
|
||||||
This change is client-only. It changes no game rules, engine commands or save data, so it needs no save migration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why
|
|
||||||
|
|
||||||
Every screen today is the same neon-edged card over a starfield, amber marks almost everything, and desktop uses a narrow center column. Key decisions are buried: job cards show gross pay, and net profit only appears in the dispatch sheet. Badges count things without saying what they are, and four tabs don't match their screen titles.
|
|
||||||
|
|
||||||
The user chose the **Deep Field** direction with **Switchyard's jobs table**, for a game played mostly on phones.
|
|
||||||
|
|
||||||
## 2. Rules
|
|
||||||
|
|
||||||
### The shell
|
|
||||||
|
|
||||||
- The solar system map is always behind the UI, and it is the Bridge. There is one map canvas for the whole session.
|
|
||||||
- Screens are glass panels over the map: a bottom sheet on phones, a right-hand column from 768 px up.
|
|
||||||
- Bottom tabs on phones, a floating left rail on desktop: **Bridge, Jobs, Fleet, More**.
|
|
||||||
- Fleet has two views, **Ships** and **Crew**. More holds **Shipyard, Places & HQs, Logbook, Dispatch Office, While you were away** and **Settings**.
|
|
||||||
- A screen's tab and its title use the same name.
|
|
||||||
|
|
||||||
### Look
|
|
||||||
|
|
||||||
| Item | Rule |
|
|
||||||
| ---- | ---- |
|
|
||||||
| Color | Navy ground; amber only for the main action and the player's own ship; blue for other flights; mint for money earned; coral for problems. |
|
|
||||||
| Type | Michroma for the wordmark and screen titles only; Instrument Sans for everything else, with tabular numerals. |
|
|
||||||
| Glass | At least 82% opaque, so text passes contrast with the Sun or a bright star behind it. |
|
|
||||||
| Art | Large on Bridge, ship detail, Shipyard and Start; small or absent in lists. |
|
|
||||||
| Removed | Corner brackets, glows on ordinary controls, amber on full gauges. |
|
|
||||||
|
|
||||||
### Needs you
|
|
||||||
|
|
||||||
The Bridge leads with one list of what needs the player, in this order: radio calls, the player's idle ship with its best job, skill points, goals ready to claim. Tab badges count the same list. The empty Alerts panel goes away.
|
|
||||||
|
|
||||||
### Jobs table
|
|
||||||
|
|
||||||
- Columns on phones: job, net, credits per hour. From 768 px, also shipper, weight and hours.
|
|
||||||
- Sorts by net (the default) or credits per hour; the choice is remembered on the device.
|
|
||||||
- Jobs the player can take now come first. Jobs they can't take sit under **Can't take yet**, each with its reason.
|
|
||||||
- **Net** keeps the dispatch sheet's definition: payout minus fuel minus the pilot's share, excluding the lender's cut.
|
|
||||||
- **Credits per hour** is net divided by the job's game hours.
|
|
||||||
- The figures use the best ship that could take the job now. A job with no such ship uses the best ship the company owns, marked as an estimate.
|
|
||||||
|
|
||||||
### Dispatch
|
|
||||||
|
|
||||||
The sheet leads with what the player clears. Payout and fuel sit beside it, ships to choose from follow, and timing facts take one line. On desktop it opens in the side panel and the table stays visible.
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
|
|
||||||
- Text meets 4.5:1 and tap targets are at least 44 px. The Deep Field colors were measured against the worst-case background; they need re-measuring in the app.
|
|
||||||
- The jobs table is a real `<table>` with `aria-sort`.
|
|
||||||
- Reduced motion stops ships moving along routes and the sheet's animation. Ships still show at their true position.
|
|
||||||
|
|
||||||
## 3. Rules for this change
|
|
||||||
|
|
||||||
- Every step leaves the game playable and the suite green.
|
|
||||||
- Every feature stays. The mockups don't draw all of them: see §6.
|
|
||||||
- Existing URLs keep working, redirecting where a screen has moved.
|
|
||||||
- Keep the accessible names tests rely on unless this document renames them. About 29 test and e2e files mention the old screen names, and about 41 source files use the old color classes.
|
|
||||||
|
|
||||||
## 4. Decisions
|
|
||||||
|
|
||||||
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5 as **0.5.165–0.5.169**.
|
|
||||||
|
|
||||||
- **0.5.165 Deep Field theme,** per §2 Look; replaces the "freight terminal HUD" (0.5.47–0.5.48).
|
|
||||||
- **0.5.166 Map as the shell.** One canvas behind every screen; the Bridge and Map merge. It pauses while the tab is hidden or a panel fully covers it.
|
|
||||||
- **0.5.167 Four tabs,** per §2 The shell; replaces the eight tabs of CR-005 and the "Bridge" label split of 0.5.118.
|
|
||||||
- **0.5.168 Needs you,** per §2; replaces the Alerts panel of 0.5.120 and redefines the badges of 0.5.49.
|
|
||||||
- **0.5.169 Jobs table and credits per hour,** per §2.
|
|
||||||
|
|
||||||
## 5. Steps — Phase 29
|
|
||||||
|
|
||||||
### Step 29.1 — Theme tokens and type
|
|
||||||
|
|
||||||
- Add the fonts; retune the `@theme` tokens in `index.css`; replace the starfield body background with the Deep Field ground; add semantic tokens for action, own ship, flight, earned and problem.
|
|
||||||
- **Test:** The suite stays green with no markup changes. Measured contrast passes for each text color on the glass.
|
|
||||||
|
|
||||||
### Step 29.2 — UI kit
|
|
||||||
|
|
||||||
- Restyle `Button`, `Panel`, `Sheet`, `Pill`, `Meter`, `StatTile` and `Eyebrow`; remove brackets and glows; set the 44 px minimums. Move literal `amber-*` and `cyan-*` classes in screens to the new tokens.
|
|
||||||
- Apply `font-title` to the wordmark and screen titles.
|
|
||||||
- Fix the phone text wraps the wider body font caused in Step 29.1: "Find work here" on the Bridge and "When you send them" on Pilots.
|
|
||||||
- **Test:** `ui-kit.test.tsx` updated. Screenshots of all screens at 390 and 1280 px.
|
|
||||||
|
|
||||||
### Step 29.3 — The map shell
|
|
||||||
|
|
||||||
- Lift `MapCanvas` from `MapScreen` into `Layout`, so it survives route changes. Add the panel host: a bottom sheet with drag detents on phones, a right column on desktop. Settle how dragging the sheet and panning the map share gestures.
|
|
||||||
- Keep tap-to-select, zoom, Reset view and the location and flight panels.
|
|
||||||
- **Test:** Navigating doesn't recreate the canvas. The canvas pauses when hidden. Frame rate and battery checked on a real mid-range phone, with the blur reduced or dropped if it can't hold up.
|
|
||||||
|
|
||||||
### Step 29.4 — Navigation
|
|
||||||
|
|
||||||
- Move to four tabs and the More screen; add Fleet's Ships and Crew views; rename titles to match tabs; add redirects from `/pilots`, `/hqs`, `/market`, `/locations` and `/map`.
|
|
||||||
- **Test:** `layout.test.tsx` and affected tests and e2e helpers updated. Each old URL lands on the right screen.
|
|
||||||
|
|
||||||
### Step 29.5 — Bridge
|
|
||||||
|
|
||||||
- Build the Needs you list and move badge counts onto it; add the next-move card; list flights; keep Fleet Asleep, loan, goals, Dispatch Office and the tutorial panel.
|
|
||||||
- **Test:** Each kind of item appears, opens the right place, and leaves when done. The badge equals the list's count.
|
|
||||||
|
|
||||||
### Step 29.6 — Jobs table
|
|
||||||
|
|
||||||
- Extract net and fuel from `pairOptions` into a shared function, so the table and sheet can't disagree. Build the table, sorting and the Can't take yet group; keep New, Priority, First contact, Yours for and board refresh.
|
|
||||||
- **Test:** Table and sheet show the same net. Sorting works and is remembered. Each blocked reason shows. Column sets match each width.
|
|
||||||
|
|
||||||
### Step 29.7 — Dispatch, Fleet and Crew
|
|
||||||
|
|
||||||
- Restyle the dispatch and deadhead sheets; build ship detail, including fuel, service bookings and upgrades; rebuild pilot cards with skills, duty, rest and independent controls.
|
|
||||||
- **Test:** Existing dispatch, fleet, service and pilot tests pass with updated selectors.
|
|
||||||
|
|
||||||
### Step 29.8 — Everything else
|
|
||||||
|
|
||||||
- Shipyard, Places & HQs, Logbook, Settings and its sub-screens, Start, Welcome, the deal, tutorial board, skill-point prompt, radio calls, While you were away, toasts, and developer tools.
|
|
||||||
- **Test:** Existing tests pass with updated selectors. Screenshots of every screen and state at 390 and 1280 px.
|
|
||||||
|
|
||||||
### Step 29.9 — Regression
|
|
||||||
|
|
||||||
- Run the full suite, type check and e2e. Audit keyboard use, screen reader labels and contrast in the built app.
|
|
||||||
- **Test:** CI is green. Manual check on a phone and on desktop through a full game: tutorial, deal, first ship, and a radio call.
|
|
||||||
|
|
||||||
## 6. Not yet designed
|
|
||||||
|
|
||||||
The canvas shows a mid-game company. These need designing during their steps, and the user reviews them there: the tutorial and deal, Welcome cards, Fleet Asleep, loan payment, service bookings, refuel flows, the deadhead sheet, independent pilot controls, empty and error states, and a large fleet (20 or more ships and long job boards).
|
|
||||||
|
|
||||||
## 7. Open questions
|
|
||||||
|
|
||||||
- **Shipyard placement:** the canvas puts it under More, but buying ships is a main goal. Reconsider if it feels buried in play.
|
|
||||||
- **Estimated figures:** whether a job with no available ship should show net at all, or only the reason it's blocked.
|
|
||||||
|
|
||||||
## 8. Amendments
|
|
||||||
|
|
||||||
| Where | Change |
|
|
||||||
| ----- | ------ |
|
|
||||||
| CR-005 | Theme and eight-tab navigation replaced by this document. |
|
|
||||||
| CR-020 | The Bridge's layout and Alerts panel replaced; its content moves per Step 29.5. |
|
|
||||||
| CR-028 | Toast styling follows the new theme; toast rules unchanged. |
|
|
||||||
|
|
||||||
## 9. Out of scope
|
|
||||||
|
|
||||||
Game rules and balance, new art, a light theme, and moving away from the Pixi map.
|
|
||||||