diff --git a/DECISIONS.md b/DECISIONS.md index ebc79fb..dc8bb17 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -858,3 +858,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Sheets portal to `document.body`.** The screen column animates in with a `transform`, which makes it the containing block for `position: fixed`; the first dispatch-sheet screenshot showed the backdrop but the sheet stranded inside the column, under the header and nav. Portalling fixes all sheets at once and keeps tests working (they query `screen`). - **Contract cards keep their texts in the same order and grouping** ("Standard (tier 2) · 50 t · …", "Needs …", "expires in …") so the board tests stay unchanged, and they contain no nested list items (tests count `listitem`s). - **Dispatch outlook cells wrap each `dt`/`dd` pair in a `div`** (valid inside `dl`), so text still reads "Trip2h 05m". + +## Step 10E.6 + +- **Market spec bars are decorative (`aria-hidden`)** and relative to the best purchasable model; the numbers stay in `dt`/`dd` text. Range uses `log10(1 + range)` because a linear bar made the Courier's 5M km invisible next to the Bulk Hauler's 1,000M km. +- **Idle/In flight pills are new visible text** on Fleet cards; they restate the ship's status, which was implied before by "At …" or a route. diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index bfd03f4..d9c302a 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -14,25 +14,50 @@ import { Countdown } from "../components/Countdown"; import { formatCredits, formatDuration, locationName, shipModelName } from "../format"; import { location, shipModel, tripRealMs } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; +import { + Art, + Button, + buttonClass, + Icon, + Meter, + Panel, + Pill, + Screen, + ScreenHeader, + Sheet, +} from "../ui"; /** All ships, their pilots and flights; any idle ship can deadhead (Steps 9.6, 10B.8). */ export function Fleet() { const state = useLoadedPlayer(); const [deadheading, setDeadheading] = useState(null); + const flying = state.ships.filter((s) => s.status === "in-flight").length; + return ( -
-

Fleet

+ + 0 + ? `${state.ships.length} ${state.ships.length === 1 ? "ship" : "ships"} · ${flying} flying` + : "Your ships" + } + icon="fleet" + /> {state.ships.length === 0 ? ( -

- No ships yet —{" "} - - visit the Market - - . -

+ + +

+ No ships yet —{" "} + + visit the Market + + . +

+
) : ( -
    +
      {state.ships.map((ship) => ( )} {deadheading && setDeadheading(null)} />} -
+ ); } @@ -58,55 +83,79 @@ function ShipRow(props: { const { ship, dispatch, pilotName } = props; const now = useClock((s) => s.now); const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0; + const model = shipModel(ship.modelId); return ( -
  • -
    - {ship.name} - {shipModelName(ship.modelId)} -
    -
    - {dispatch - ? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}` - : `At ${locationName(ship.locationId ?? "")}`} - {" · "} - {pilotName ?? "No pilot"} + +
    +
    + +
    +
    +
    + + {ship.name} + + {dispatch ? ( + + In flight + + ) : ( + Idle + )} +
    +
    {shipModelName(ship.modelId)}
    +
    + {dispatch + ? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}` + : `At ${locationName(ship.locationId ?? "")}`} + {" · "} + + {pilotName ?? "No pilot"} + +
    +
    {dispatch && ( -
    -
    -
    -
    -
    - Arrives in +
    + +
    + {progress}% + + Arrives in{" "} + + + +
    )} - {ship.status === "idle" && ship.locationId && ( - - Find work here - - )} {ship.status === "idle" && ( - +
    + {ship.locationId && ( + + + Find work here + + )} + +
    )} -
  • + ); } @@ -165,21 +214,16 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v }); return ( -
    -
    -

    - Deadhead {ship.name} -

    -

    - Fly empty from {locationName(origin)} to another place you know. You pay fuel only. -

    -
      - {options.map((option) => ( + +
        + {options.map((option) => { + const sprite = content.location(option.id)?.sprite; + return (
      • - ))} -
      - {reposition.isError && ( -

      - {errorMessage(reposition.error)} -

      - )} - -
    -
    + ); + })} + + {reposition.isError && ( +

    + {errorMessage(reposition.error)} +

    + )} + + ); } diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index 6af3185..96b81b7 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -2,9 +2,19 @@ import { content, type ShipModel } from "@deadhead/content"; import { useState } from "react"; import { formatCredits, formatMillionKm, locationName } from "../format"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; +import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui"; const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" }; +const MODELS = content.purchasableShipModels; +const best = { + speed: Math.max(...MODELS.map((m) => m.cruiseSpeedKmPerGameHour)), + capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)), + range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)), +}; +/** Range spans 5 to 1,000M km, so its bar uses a log scale to stay readable. */ +const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range); + /** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */ export function Market() { const state = useLoadedPlayer(); @@ -12,50 +22,78 @@ export function Market() { const [buying, setBuying] = useState(null); return ( -
    -

    Market

    -

    - New ships are delivered with no pilot to one of your HQs. -

    + + {buy.isError && ( -

    +

    {errorMessage(buy.error)}

    )} {buy.isSuccess && ( -

    +

    + {buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.

    )} -
      - {content.purchasableShipModels.map((model) => { +
        + {MODELS.map((model) => { const affordable = state.company.credits >= model.price; return ( -
      • -
        - {model.name} - {formatCredits(model.price)} + +
        +
        +
        -
        -
        Speed
        -
        {formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h
        -
        Capacity
        -
        {model.cargoCapacityTonnes} t
        -
        Range
        -
        {formatMillionKm(model.fuelRangeMillionKm)}
        -
        Landing
        -
        {LANDING_LABELS[model.landingClass]}
        +
        + {model.name} + + {formatCredits(model.price)} + +
        +
        + + + +
        - -
      • + + ); })}
      @@ -69,7 +107,30 @@ export function Market() { onClose={() => setBuying(null)} /> )} -
    + + ); +} + +/** One spec line: label and value (dt directly before dd), with a bar relative to the best model. */ +function Spec(props: { icon: IconName; label: string; value: string; share?: number }) { + return ( +
    +
    +
    + + {props.label} +
    +
    {props.value}
    +
    + {props.share !== undefined && ( + ); } @@ -84,52 +145,45 @@ function DeliveryPicker(props: { const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null); return ( -
    -
    -

    - Deliver to which HQ? -

    -

    - Your {props.model.name} will wait there, idle and without a pilot. -

    -
    - HQ - {state.bays.map((bay) => ( + +
    + HQ + {state.bays.map((bay) => { + const sprite = content.location(bay.locationId)?.sprite; + return ( - ))} -
    - - -
    -
    + ); + })} + + + + ); } diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 8aa0bab..217c8b5 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -184,3 +184,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde - [x] **Step 10E.3 — App shell.** `Layout`: HUD header (logo mark, wordmark from 420 px, glowing credits chip, clock chip; `Credits` / `Game clock` labels kept) and icon navigation, a bottom bar on phones and a 96 px left rail from `md` (decision 0.5.49). `SCREENS` gained an `icon`. Attention badges from the pure `tabAttention(state)` (`layout/attention.ts`): HQ = goals ready, Jobs = idle crewed ships whose pilot isn't resting (1 during the tutorial until a run is in flight), Pilots = pilots with unspent skill points; badges are `aria-hidden`. `--header-h`/`--nav-h` CSS variables let the map fill exactly the space left on both layouts. `ui/Screen` is the content column (max 48 rem, rise-in). App loading, error and offline states use the kit. Test: `attention.test.tsx` (counts; badge doesn't change the tab's name); `layout.test.tsx` unchanged and passing. - [x] **Step 10E.4 — HQ dashboard.** `ScreenHeader` with the company name; icon stat tiles (2 × 2 on phones, 4 across from `md`); two columns from `md`. New `components/RouteStrip` (origin art → dashed line → destination art; with `progress` the line fills and the ship's art rides along; `aria-hidden`, place names drawn with CSS `content` so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; `GoalsCard` rows with check discs, reward amounts and a glowing Claim; `LoanCard` with an emerald `Meter` and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. **Manual check:** scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9). - [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9). +- [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.