Step 10E.4: HQ dashboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1daed1d251
commit
a69ffcc434
|
|
@ -846,3 +846,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Jobs badge during the tutorial is 1 until a run is in flight**: the loaner isn't a ship in state, but "go fly a run" is the one thing the player should do next.
|
- **Jobs badge during the tutorial is 1 until a run is in flight**: the loaner isn't a ship in state, but "go fly a run" is the one thing the player should do next.
|
||||||
- **The desktop rail is 96 px with labels under icons**, not a wide sidebar, so the map and screens keep the room. The wordmark hides below 420 px wide so the credits and clock chips fit on small phones.
|
- **The desktop rail is 96 px with labels under icons**, not a wide sidebar, so the map and screens keep the room. The wordmark hides below 420 px wide so the credits and clock chips fit on small phones.
|
||||||
- **Screenshot stack:** a scratchpad Playwright harness (playwright-core driving local Chrome) seeds a tutorial save and a mid-game save through the API on a separate database (`deadhead_shots`, API 3125, web 5199), as in Step 10C.
|
- **Screenshot stack:** a scratchpad Playwright harness (playwright-core driving local Chrome) seeds a tutorial save and a mid-game save through the API on a separate database (`deadhead_shots`, API 3125, web 5199), as in Step 10C.
|
||||||
|
|
||||||
|
## Step 10E.4
|
||||||
|
|
||||||
|
- **"Fly a company run" on the tutorial HQ panel is the one copy addition so far**: a link to Jobs, shown only when no run is in flight and no deal is waiting. It adds a path; nothing existing changed (decision 0.5.51 is about not changing existing copy and names).
|
||||||
|
- **`ScreenHeader` is a `div`, not a `header`**: a second `<header>` was exposed as another `banner` landmark next to the app header.
|
||||||
|
- **Route strip labels are CSS-generated** (`after:content-[attr(data-label)]`) because tests and screen readers already get the route as text; DOM text in a decorative strip would duplicate it.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { formatCredits } from "../format";
|
import { formatCredits } from "../format";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Button, Icon, Panel } from "../ui";
|
||||||
|
|
||||||
/** How many unclaimed goals HQ shows at once (Step 10A.15). */
|
/** How many unclaimed goals HQ shows at once (Step 10A.15). */
|
||||||
export const GOALS_SHOWN = 3;
|
export const GOALS_SHOWN = 3;
|
||||||
|
|
@ -13,46 +14,61 @@ export function GoalsCard() {
|
||||||
if (next.length === 0) return null;
|
if (next.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<Panel as="section" feature id="goals" aria-labelledby="goals-title">
|
||||||
id="goals"
|
<h2 id="goals-title" className="flex items-center gap-2 font-display text-lg font-bold">
|
||||||
aria-labelledby="goals-title"
|
<Icon name="target" className="size-5 text-amber-300" />
|
||||||
className="rounded-lg border border-slate-800 p-3"
|
|
||||||
>
|
|
||||||
<h2 id="goals-title" className="font-semibold">
|
|
||||||
Goals
|
Goals
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
{next.map((goal) => {
|
{next.map((goal) => {
|
||||||
const details = content.onboardingGoal(goal.id);
|
const details = content.onboardingGoal(goal.id);
|
||||||
if (!details) return null;
|
if (!details) return null;
|
||||||
const ready = goal.status === "ready";
|
const ready = goal.status === "ready";
|
||||||
return (
|
return (
|
||||||
<li key={goal.id} className="flex items-center justify-between gap-2 text-sm">
|
<li
|
||||||
<span className={ready ? "text-emerald-300" : "text-slate-300"}>
|
key={goal.id}
|
||||||
{details.title}
|
className={`flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||||
<span className="ml-2 text-xs text-slate-400">
|
ready
|
||||||
+{formatCredits(details.reward)}
|
? "border-emerald-400/50 bg-emerald-500/10 shadow-glow-emerald"
|
||||||
|
: "border-space-700 bg-space-950/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`grid size-5 shrink-0 place-items-center rounded-full ring-1 ${
|
||||||
|
ready ? "bg-emerald-400 text-space-950 ring-emerald-300" : "ring-space-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ready && <Icon name="check" className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className={ready ? "text-emerald-200" : "text-ink-200"}>
|
||||||
|
{details.title}
|
||||||
|
<span className="ml-2 font-display text-xs font-semibold whitespace-nowrap text-amber-300/90">
|
||||||
|
+{formatCredits(details.reward)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{ready && (
|
{ready && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="success"
|
||||||
|
size="sm"
|
||||||
disabled={claim.isPending}
|
disabled={claim.isPending}
|
||||||
onClick={() => claim.mutate({ goalId: goal.id })}
|
onClick={() => claim.mutate({ goalId: goal.id })}
|
||||||
className="shrink-0 rounded-md bg-emerald-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
|
className="shrink-0 animate-glow"
|
||||||
>
|
>
|
||||||
Claim
|
Claim
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{claim.isError && (
|
{claim.isError && (
|
||||||
<p role="alert" className="mt-2 text-xs text-red-400">
|
<p role="alert" className="mt-2 text-xs text-rose-300">
|
||||||
{errorMessage(claim.error)}
|
{errorMessage(claim.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { content } from "@deadhead/content";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { formatCredits } from "../format";
|
import { formatCredits } from "../format";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Button, Meter, Panel } from "../ui";
|
||||||
|
|
||||||
/** Why a pay-down amount cannot be sent, or null if it can. */
|
/** Why a pay-down amount cannot be sent, or null if it can. */
|
||||||
export function payDownProblem(amount: number, credits: number, balance: number): string | null {
|
export function payDownProblem(amount: number, credits: number, balance: number): string | null {
|
||||||
|
|
@ -23,28 +24,24 @@ export function LoanCard() {
|
||||||
const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);
|
const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-700 p-3">
|
<Panel>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<h2 className="font-semibold">Loan from Halvorsen</h2>
|
<h2 className="font-display text-lg font-bold">Loan from Halvorsen</h2>
|
||||||
<span aria-label="Loan balance" className="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>
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-ink-400">
|
||||||
{Math.round(content.config.loanRepaymentShare * 100)}% of every payout goes to the lender
|
{Math.round(content.config.loanRepaymentShare * 100)}% of every payout goes to the lender
|
||||||
until it's paid off.
|
until it's paid off.
|
||||||
</p>
|
</p>
|
||||||
<div
|
<Meter label="Loan paid off" value={paidOff} tone="emerald" className="mt-3" />
|
||||||
role="progressbar"
|
<p className="mt-1 text-right font-display text-xs text-emerald-300/80">
|
||||||
aria-label="Loan paid off"
|
{paidOff}% paid off
|
||||||
aria-valuemin={0}
|
</p>
|
||||||
aria-valuemax={100}
|
|
||||||
aria-valuenow={paidOff}
|
|
||||||
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
|
|
||||||
>
|
|
||||||
<div className="h-full bg-emerald-400" style={{ width: `${paidOff}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-right text-xs text-slate-400">{paidOff}% paid off</p>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="mt-2 flex items-center gap-2"
|
className="mt-2 flex items-center gap-2"
|
||||||
|
|
@ -62,22 +59,18 @@ 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-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
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"
|
|
||||||
disabled={input === "" || problem !== null || repay.isPending}
|
|
||||||
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Pay down
|
Pay down
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{problem && <p className="mt-1 text-xs text-red-400">{problem}</p>}
|
{problem && <p className="mt-1 text-xs text-rose-300">{problem}</p>}
|
||||||
{repay.isError && (
|
{repay.isError && (
|
||||||
<p role="alert" className="mt-1 text-xs text-red-400">
|
<p role="alert" className="mt-1 text-xs text-rose-300">
|
||||||
{errorMessage(repay.error)}
|
{errorMessage(repay.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { locationName } from "../format";
|
||||||
|
import { Art, Icon } from "../ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin art → flight line → destination art (CR-005). With `progress` (0–1)
|
||||||
|
* the line fills and the ship's art rides along it. Decorative: the route is
|
||||||
|
* always in text nearby, so the strip is hidden from screen readers.
|
||||||
|
*/
|
||||||
|
export function RouteStrip({
|
||||||
|
originId,
|
||||||
|
destinationId,
|
||||||
|
progress,
|
||||||
|
shipSprite,
|
||||||
|
size = "md",
|
||||||
|
}: {
|
||||||
|
originId: string;
|
||||||
|
destinationId: string;
|
||||||
|
progress?: number;
|
||||||
|
shipSprite?: string;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}) {
|
||||||
|
const percent = progress === undefined ? null : Math.max(0, Math.min(1, progress)) * 100;
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true" className="flex items-center gap-1">
|
||||||
|
<Stop id={originId} size={size} />
|
||||||
|
<div className="relative h-8 flex-1">
|
||||||
|
<div className="absolute inset-x-0 top-1/2 border-t-2 border-dashed border-cyan-400/35" />
|
||||||
|
{percent === null ? (
|
||||||
|
<Icon
|
||||||
|
name="arrow"
|
||||||
|
className="absolute top-1/2 left-1/2 size-4 -translate-1/2 rounded-full bg-space-900 text-cyan-300"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 left-0 h-0.5 -translate-y-1/2 rounded-full bg-linear-to-r from-cyan-500/40 to-cyan-300 shadow-glow-cyan"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute top-1/2 -translate-1/2 transition-[left] duration-1000"
|
||||||
|
style={{ left: `${percent}%` }}
|
||||||
|
>
|
||||||
|
{shipSprite ? (
|
||||||
|
<Art
|
||||||
|
sprite={shipSprite}
|
||||||
|
className="size-8 rotate-90 drop-shadow-[0_0_6px_rgb(34_211_238/0.7)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="block size-2.5 rounded-full bg-cyan-200 shadow-glow-cyan" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Stop id={destinationId} size={size} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop({ id, size }: { id: string; size: "sm" | "md" }) {
|
||||||
|
const sprite = content.location(id)?.sprite;
|
||||||
|
return (
|
||||||
|
<div className={`flex shrink-0 flex-col items-center ${size === "sm" ? "w-12" : "w-16"}`}>
|
||||||
|
<div
|
||||||
|
className={`grid place-items-center ${size === "sm" ? "size-7" : "size-10"} rounded-full`}
|
||||||
|
>
|
||||||
|
{sprite ? (
|
||||||
|
<Art
|
||||||
|
sprite={sprite}
|
||||||
|
className={`${size === "sm" ? "size-7" : "size-10"} drop-shadow-[0_0_8px_rgb(125_211_252/0.35)]`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="size-3 rounded-full bg-ink-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* CSS-drawn label: the route is already in text nearby, so keep it out of the DOM. */}
|
||||||
|
<span
|
||||||
|
data-label={locationName(id)}
|
||||||
|
className="mt-0.5 max-w-full truncate font-display text-[10px] font-semibold tracking-wider text-ink-300 uppercase after:content-[attr(data-label)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 60% at 50% 0%, rgb(56 189 248 / 0.1), transparent 70%),
|
radial-gradient(ellipse 80% 60% at 50% 0%, rgb(56 189 248 / 0.1), transparent 70%),
|
||||||
linear-gradient(180deg, rgb(3 5 13 / 0.72), rgb(3 5 13 / 0.9)),
|
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,
|
url("/assets/map-gen/bg.webp") center / cover no-repeat,
|
||||||
var(--color-space-950);
|
var(--color-space-950);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { TutorialState } from "@deadhead/schema";
|
import type { TutorialState } from "@deadhead/schema";
|
||||||
import { realToGame } from "@deadhead/sim";
|
import { dispatchProgress, realToGame } from "@deadhead/sim";
|
||||||
|
import { Link } from "react-router";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { formatGameTime } from "../clock/GameClock";
|
import { formatGameTime } from "../clock/GameClock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
import { GoalsCard } from "../components/GoalsCard";
|
import { GoalsCard } from "../components/GoalsCard";
|
||||||
import { LoanCard } from "../components/LoanCard";
|
import { LoanCard } from "../components/LoanCard";
|
||||||
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
import { formatCredits, locationName } from "../format";
|
import { formatCredits, locationName } from "../format";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
import { buttonClass, Eyebrow, Icon, Panel, Screen, ScreenHeader, StatTile } from "../ui";
|
||||||
|
|
||||||
/** What the player needs to do next to reach the deal (decision 0.5.30). */
|
/** 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 {
|
||||||
|
|
@ -32,72 +35,130 @@ export function HQ() {
|
||||||
const resting = state.pilots
|
const resting = state.pilots
|
||||||
.filter((p) => p.resting && p.restEndsAt !== null)
|
.filter((p) => p.resting && p.restEndsAt !== null)
|
||||||
.sort((a, b) => a.restEndsAt! - b.restEndsAt!);
|
.sort((a, b) => a.restEndsAt! - b.restEndsAt!);
|
||||||
|
const nextShip = next?.tutorial
|
||||||
|
? "ship-company-courier"
|
||||||
|
: content.shipModel(state.ships.find((s) => s.id === next?.shipId)?.modelId ?? "")?.sprite;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 p-4">
|
<Screen className="max-w-5xl">
|
||||||
<h1 className="text-xl font-semibold">HQ</h1>
|
<ScreenHeader title="HQ" eyebrow={state.company.name} icon="hq" />
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<Stat label="Credits" value={formatCredits(state.company.credits)} />
|
<StatTile
|
||||||
<Stat label="Game date" value={formatGameTime(realToGame(now, content.config))} />
|
label="Credits"
|
||||||
<Stat label="Ships idle" value={String(idle)} />
|
value={formatCredits(state.company.credits)}
|
||||||
<Stat label="Ships in flight" value={String(flying)} />
|
icon="credits"
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Game date"
|
||||||
|
value={formatGameTime(realToGame(now, content.config))}
|
||||||
|
icon="clock"
|
||||||
|
/>
|
||||||
|
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
|
||||||
|
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-slate-800 p-3">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<h2 className="text-sm font-semibold text-slate-300">Next arrival</h2>
|
<div className="space-y-4">
|
||||||
{next ? (
|
{state.tutorial && (
|
||||||
<p className="mt-1">
|
<Panel feature tone="amber">
|
||||||
{locationName(next.originId)} → {locationName(next.destinationId)} in{" "}
|
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
|
||||||
<Countdown to={next.arrivalAt} />
|
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
|
||||||
</p>
|
Flying for Halvorsen Lunar Freight
|
||||||
) : (
|
</h2>
|
||||||
<p className="mt-1 text-sm text-slate-400">Nothing in flight.</p>
|
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
|
||||||
)}
|
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
||||||
</div>
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
{!state.tutorial && (
|
<Panel feature tone={next ? "cyan" : "default"}>
|
||||||
<div aria-label="Pilot rest" className="rounded-lg border border-slate-800 p-3">
|
<h2 className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase">
|
||||||
<h2 className="text-sm font-semibold text-slate-300">Pilots resting: {resting.length}</h2>
|
Next arrival
|
||||||
{resting[0] ? (
|
</h2>
|
||||||
<p className="mt-1">
|
{next ? (
|
||||||
{resting[0].name} wakes in <Countdown to={resting[0].restEndsAt!} done="now" />
|
<>
|
||||||
</p>
|
<div className="mt-2">
|
||||||
) : (
|
<RouteStrip
|
||||||
<p className="mt-1 text-sm text-slate-400">Everyone is ready to fly.</p>
|
originId={next.originId}
|
||||||
|
destinationId={next.destinationId}
|
||||||
|
progress={dispatchProgress(next, now)}
|
||||||
|
shipSprite={nextShip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm text-ink-200">
|
||||||
|
{locationName(next.originId)} → {locationName(next.destinationId)} in
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-2xl font-bold text-cyan-200">
|
||||||
|
<Countdown to={next.arrivalAt} />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{!state.tutorial && (
|
||||||
|
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
|
||||||
|
<Icon name="rest" className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
|
||||||
|
Pilots resting: {resting.length}
|
||||||
|
</h2>
|
||||||
|
{resting[0] ? (
|
||||||
|
<p className="text-sm text-sky-200">
|
||||||
|
{resting[0].name} wakes in{" "}
|
||||||
|
<Countdown to={resting[0].restEndsAt!} done="now" />
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!state.tutorial && state.ships.length === 0 && (
|
||||||
|
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!state.tutorial && state.ships.length === 0 && (
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p>
|
{!state.tutorial && <GoalsCard />}
|
||||||
)}
|
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
||||||
|
|
||||||
{state.tutorial && (
|
|
||||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
|
|
||||||
<h2 className="font-semibold text-amber-300">Flying for Halvorsen Lunar Freight</h2>
|
|
||||||
<p aria-label="Company runs" className="mt-1 text-sm text-slate-300">
|
|
||||||
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
|
||||||
{runsBeforeDeal}
|
|
||||||
</p>
|
|
||||||
<p aria-label="Next step" className="mt-1 text-xs text-slate-400">
|
|
||||||
{tutorialHint(state.tutorial, runsBeforeDeal)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</Screen>
|
||||||
{!state.tutorial && <GoalsCard />}
|
|
||||||
|
|
||||||
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: string }) {
|
/** One pip per company run toward the deal. */
|
||||||
|
function RunPips({ done, total }: { done: number; total: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-800 p-3">
|
<div aria-hidden="true" className="mt-2 flex gap-1.5">
|
||||||
<div className="text-xs text-slate-400">{label}</div>
|
{Array.from({ length: total }, (_, i) => (
|
||||||
<div className="mt-1 font-semibold tabular-nums">{value}</div>
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`h-2 flex-1 rounded-full ${i < done ? "bg-amber-400 shadow-glow-amber" : "bg-space-700"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ export function ScreenHeader({
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header 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 && (
|
||||||
<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">
|
<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">
|
||||||
|
|
@ -156,7 +156,7 @@ export function ScreenHeader({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{actions}
|
{actions}
|
||||||
</header>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -182,3 +182,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde
|
||||||
- [x] **Step 10E.1 — Fonts, theme tokens and backdrop.** `@fontsource/chakra-petch` (display) and `@fontsource/barlow` (body), self-hosted. `index.css` `@theme`: `font-display`/`font-sans`, `space-*` surfaces, `ink-*` text, glow and panel shadows, `rise`/`glow`/`sheet` animations. `body::before` puts the map's `bg.webp` under a navy gradient behind every screen; global `:focus-visible` ring; all motion off under `prefers-reduced-motion`. Test: `theme.test.ts`.
|
- [x] **Step 10E.1 — Fonts, theme tokens and backdrop.** `@fontsource/chakra-petch` (display) and `@fontsource/barlow` (body), self-hosted. `index.css` `@theme`: `font-display`/`font-sans`, `space-*` surfaces, `ink-*` text, glow and panel shadows, `rise`/`glow`/`sheet` animations. `body::before` puts the map's `bg.webp` under a navy gradient behind every screen; global `:focus-visible` ring; all motion off under `prefers-reduced-motion`. Test: `theme.test.ts`.
|
||||||
- [x] **Step 10E.2 — UI kit.** `apps/web/src/ui/` (barrel `index.ts`): `Button`/`buttonClass` (primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback), `Panel` (tones, `feature` corner brackets, polymorphic `as`) and `Eyebrow`, `Sheet` (modal: bottom sheet on phones, centred card from `sm`, renders its own `h2`), `Pill`, `Meter` (labelled `progressbar`), `StatTile`, `ScreenHeader` (the screen's `h1`), `Art`/`spriteUrl` (generated map sprites, 1x/2x `srcSet`, hides on error) and `Icon` (28 line icons). Test: `ui-kit.test.tsx`.
|
- [x] **Step 10E.2 — UI kit.** `apps/web/src/ui/` (barrel `index.ts`): `Button`/`buttonClass` (primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback), `Panel` (tones, `feature` corner brackets, polymorphic `as`) and `Eyebrow`, `Sheet` (modal: bottom sheet on phones, centred card from `sm`, renders its own `h2`), `Pill`, `Meter` (labelled `progressbar`), `StatTile`, `ScreenHeader` (the screen's `h1`), `Art`/`spriteUrl` (generated map sprites, 1x/2x `srcSet`, hides on error) and `Icon` (28 line icons). Test: `ui-kit.test.tsx`.
|
||||||
- [x] **Step 10E.3 — App shell.** `Layout`: HUD header (logo mark, wordmark from 420 px, glowing credits chip, clock chip; `Credits` / `Game clock` labels kept) and icon navigation, a bottom bar on phones and a 96 px left rail from `md` (decision 0.5.49). `SCREENS` gained an `icon`. Attention badges from the pure `tabAttention(state)` (`layout/attention.ts`): HQ = goals ready, Jobs = idle crewed ships whose pilot isn't resting (1 during the tutorial until a run is in flight), Pilots = pilots with unspent skill points; badges are `aria-hidden`. `--header-h`/`--nav-h` CSS variables let the map fill exactly the space left on both layouts. `ui/Screen` is the content column (max 48 rem, rise-in). App loading, error and offline states use the kit. Test: `attention.test.tsx` (counts; badge doesn't change the tab's name); `layout.test.tsx` unchanged and passing.
|
- [x] **Step 10E.3 — App shell.** `Layout`: HUD header (logo mark, wordmark from 420 px, glowing credits chip, clock chip; `Credits` / `Game clock` labels kept) and icon navigation, a bottom bar on phones and a 96 px left rail from `md` (decision 0.5.49). `SCREENS` gained an `icon`. Attention badges from the pure `tabAttention(state)` (`layout/attention.ts`): HQ = goals ready, Jobs = idle crewed ships whose pilot isn't resting (1 during the tutorial until a run is in flight), Pilots = pilots with unspent skill points; badges are `aria-hidden`. `--header-h`/`--nav-h` CSS variables let the map fill exactly the space left on both layouts. `ui/Screen` is the content column (max 48 rem, rise-in). App loading, error and offline states use the kit. Test: `attention.test.tsx` (counts; badge doesn't change the tab's name); `layout.test.tsx` unchanged and passing.
|
||||||
|
- [x] **Step 10E.4 — HQ dashboard.** `ScreenHeader` with the company name; icon stat tiles (2 × 2 on phones, 4 across from `md`); two columns from `md`. New `components/RouteStrip` (origin art → dashed line → destination art; with `progress` the line fills and the ship's art rides along; `aria-hidden`, place names drawn with CSS `content` so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; `GoalsCard` rows with check discs, reward amounts and a glowing Claim; `LoanCard` with an emerald `Meter` and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. **Manual check:** scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue