From 939ee459435ba3b2614e06ca78c2d967a3336d3a Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 22:42:25 -0400 Subject: [PATCH] Step 10E.5: contracts, tutorial board and dispatch sheet Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 + apps/web/src/screens/Contracts.tsx | 11 +- apps/web/src/screens/DispatchSheet.tsx | 189 +++++++++++++----------- apps/web/src/screens/LocationBoards.tsx | 134 ++++++++++------- apps/web/src/screens/TutorialBoard.tsx | 109 ++++++++------ apps/web/src/ui/Sheet.tsx | 10 +- memory-bank/progress.md | 1 + 7 files changed, 271 insertions(+), 189 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index cefe652..ebc79fb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -852,3 +852,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **"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 `
` was exposed as another `banner` landmark next to the app header. - **Route strip labels are CSS-generated** (`after:content-[attr(data-label)]`) because tests and screen readers already get the route as text; DOM text in a decorative strip would duplicate it. + +## Step 10E.5 + +- **Sheets portal to `document.body`.** The screen column animates in with a `transform`, which makes it the containing block for `position: fixed`; the first dispatch-sheet screenshot showed the backdrop but the sheet stranded inside the column, under the header and nav. Portalling fixes all sheets at once and keeps tests working (they query `screen`). +- **Contract cards keep their texts in the same order and grouping** ("Standard (tier 2) · 50 t · …", "Needs …", "expires in …") so the board tests stay unchanged, and they contain no nested list items (tests count `listitem`s). +- **Dispatch outlook cells wrap each `dt`/`dd` pair in a `div`** (valid inside `dl`), so text still reads "Trip2h 05m". diff --git a/apps/web/src/screens/Contracts.tsx b/apps/web/src/screens/Contracts.tsx index eb67002..922b0e9 100644 --- a/apps/web/src/screens/Contracts.tsx +++ b/apps/web/src/screens/Contracts.tsx @@ -1,6 +1,7 @@ import type { Contract } from "@deadhead/schema"; import { useState } from "react"; import { useLoadedPlayer } from "../state/actions"; +import { Screen, ScreenHeader } from "../ui"; import { LocationBoards } from "./LocationBoards"; import { DispatchSheet } from "./DispatchSheet"; import { TutorialBoard } from "./TutorialBoard"; @@ -10,10 +11,14 @@ export function Contracts() { const state = useLoadedPlayer(); const [selected, setSelected] = useState(null); return ( -
-

Contracts

+ + {state.tutorial ? : } {selected && setSelected(null)} />} -
+ ); } diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 989f4a7..085af98 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -26,6 +26,8 @@ import { import { requirementsText } from "./LocationBoards"; import { location, shipModel, tripRealMs } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; +import { RouteStrip } from "../components/RouteStrip"; +import { Art, Button, Sheet } from "../ui"; /** Short player-facing reasons a ship+pilot cannot take a contract. */ export const INELIGIBLE_REASONS: Record = { @@ -107,6 +109,8 @@ export interface PairOption { shipId: string; shipName: string; modelName: string; + /** The model's art (decision 0.5.50). */ + modelSprite: string; pilotId: string | null; pilotName: string | null; /** Why this pair cannot take the contract; empty when it can. */ @@ -186,6 +190,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) shipId: ship.id, shipName: ship.name, modelName: model.name, + modelSprite: model.sprite, pilotId: pilot?.id ?? null, pilotName: pilot?.name ?? null, problems, @@ -223,119 +228,129 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo }; return ( -
-
-

- {locationName(contract.originId)} → {locationName(contract.destinationId)} -

-

- {cargoName(contract.cargoTypeId)} - {contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "} - {formatCredits(contract.payout)} - {" · "} - {requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"} -

+ +

+ {cargoName(contract.cargoTypeId)} + {contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "} + {formatCredits(contract.payout)} + {" · "} + {requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"} +

+
+ +
- {options.length === 0 ? ( -

- No idle ships at {locationName(contract.originId)}. -

- ) : ( -
    - {options.map((option) => ( + {options.length === 0 ? ( +

    + No idle ships at {locationName(contract.originId)}. +

    + ) : ( +
      + {options.map((option) => { + const blocked = option.problems.length > 0; + const picked = chosen === option.shipId; + return (
    + )} - {dispatch.isError && ( -

    - {errorMessage(dispatch.error)} -

    - )} -
    - - -
    + {dispatch.isError && ( +

    + {errorMessage(dispatch.error)} +

    + )} +
    + +
    + + ); +} + +/** One number in a pair's outlook; `dt` directly before `dd` ("Trip2h 05m"). */ +function Cell({ label, value, good }: { label: string; value: string; good?: boolean }) { + return ( +
    +
    {label}
    +
    + {value} +
    ); } diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index 2814c65..02bbcce 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -17,6 +17,9 @@ import { distanceAt } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { pairOptions } from "./DispatchSheet"; import { DeadheadPicker } from "./Fleet"; +import { content } from "@deadhead/content"; +import { RouteStrip } from "../components/RouteStrip"; +import { Art, Button, Icon, Panel, Pill } from "../ui"; export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const; @@ -54,23 +57,29 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) = state.ships.filter((s) => s.status === "idle" && s.locationId === id).length; return ( -
    -
    +
    +
    {discovered.map((id) => { const idle = idleAt(id); + const sprite = content.location(id)?.sprite; return (
    {idleAt(locationId) === 0 && ( -

    +

    + No idle ships here. Deadhead one in from the Fleet screen to take these jobs.

    )} @@ -108,77 +118,97 @@ function LocationBoard({ }, }); - if (board.isPending) return

    Loading contracts…

    ; + if (board.isPending) return

    Loading contracts…

    ; if (board.isError) { return ( -
    +
    Could not load contracts.{" "} - +
    ); } const refresh = ( -

    +

    + New contracts in{" "} - + + +

    ); const stuck = ; if (board.data.contracts.length === 0) { return ( -
    +
    {refresh} {stuck} -

    No contracts left this period.

    + + No contracts left this period. +
    ); } return ( -
    +
    {refresh} {stuck} -
      +
        {board.data.contracts.map((contract) => { const requirements = requirementsText(contract); return ( -
      • -
        - + +
        + {locationName(contract.originId)} → {locationName(contract.destinationId)} {contract.firstContact && ( - + First contact - + )} - + {formatCredits(contract.payout)}
        -
        - {cargoName(contract.cargoTypeId)} - {contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "} - {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))} + +
        + + + {cargoName(contract.cargoTypeId)} + {contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "} + {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))} +
        -
        - - {requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "} - +
        + + + {requirements ? `Needs ${requirements}` : "No skills needed"} + {" "} + · expires in {onSelect && ( - + )}
        -
      • + ); })}
      @@ -218,37 +248,37 @@ function NothingHere({ {stuck.map((ship) => { const pilot = state.pilots.find((p) => p.assignedShipId === ship.id)!; return ( -
      -

      +

      + No job here that {ship.name} and {pilot.name} can take.

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

      +

      {errorMessage(rest.error)}

      )} diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index 9fd2e03..7d934c4 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -6,6 +6,8 @@ import { useClock } from "../clock/clock"; import { formatCredits, formatDuration, locationName, cargoName } from "../format"; import { distanceAt, tripRealMs } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; +import { RouteStrip } from "../components/RouteStrip"; +import { Art, Button, Icon, Panel, Pill } from "../ui"; export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const; @@ -23,46 +25,49 @@ export function TutorialBoard() { const loaner = loanerModel(content); const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0; - if (board.isPending) return

      Loading contracts…

      ; + if (board.isPending) return

      Loading contracts…

      ; if (board.isError) { return ( -
      +
      Could not load contracts.{" "} - +
      ); } return ( -
      +
      {firstRun && ( -
      -

      Halvorsen Lunar Freight needs a pilot.

      -

      Their ship, your hands. Get the crates to the Moon.

      -
      + + +
      +

      + Halvorsen Lunar Freight needs a pilot. +

      +

      Their ship, your hands. Get the crates to the Moon.

      +
      +
      )} -

      +

      Company runs in the loaner {loaner.name}. You keep a share of each payout.

      {runInFlight && ( -

      +

      + Your run is in flight. Wait for it to land.

      )} {fly.isError && ( -

      +

      {errorMessage(fly.error)}

      )} {board.data.contracts.length === 0 && ( -

      No company runs left this period. Check back soon.

      +

      No company runs left this period. Check back soon.

      )} -
        +
          {board.data.contracts.map((contract, index) => { // On the first run, suggest the lightest (fastest) contract. const suggested = firstRun && index === 0; @@ -81,74 +86,90 @@ export function TutorialBoard() { contract.tier, content.config, ); - const border = scripted - ? "border-fuchsia-500 bg-fuchsia-500/10" - : suggested - ? "border-amber-400 ring-1 ring-amber-400" - : "border-slate-800"; return ( -
        • -
          - +
          + {scripted ? ( - {scripted.name} + {scripted.name} ) : ( <> {locationName(contract.originId)} → {locationName(contract.destinationId)} )} {suggested && ( - + Suggested - + )} - + + {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
          {scripted && ( -

          +

          {locationName(contract.originId)} → {locationName(contract.destinationId)} · double pay, no questions asked

          )} -
          -
          + +
          +
          -
          Pay
          -
          +
          + Pay +
          +
          {formatCredits(contract.payout)}
          -
          Time
          -
          +
          + Time +
          +
          {formatDuration(tripMs)}
          -
          XP
          -
          +
          + XP +
          +
          +{xp}
          - +
          -
        • + ); })}
        diff --git a/apps/web/src/ui/Sheet.tsx b/apps/web/src/ui/Sheet.tsx index 5586773..d26ee27 100644 --- a/apps/web/src/ui/Sheet.tsx +++ b/apps/web/src/ui/Sheet.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; import type { Tone } from "./Panel"; const TITLE_TONES: Partial> = { @@ -14,7 +15,9 @@ const BORDER_TONES: Partial> = { /** * A modal dialog: a bottom sheet on phones, a centred card from `sm` up. - * Labelled by its title, which it renders as an `h2` with `titleId`. + * Labelled by its title, which it renders as an `h2` with `titleId`. Portalled + * to `body`: a transformed ancestor (the screens' rise-in) would otherwise + * become the containing block of `position: fixed`. */ export function Sheet({ titleId, @@ -34,7 +37,7 @@ export function Sheet({ layer?: string; children: ReactNode; }) { - return ( + return createPortal(
        @@ -62,6 +65,7 @@ export function Sheet({ {subtitle &&
        {subtitle}
        } {children}
        -
      +
      , + document.body, ); } diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 826faad..8aa0bab 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -183,3 +183,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde - [x] **Step 10E.2 — UI kit.** `apps/web/src/ui/` (barrel `index.ts`): `Button`/`buttonClass` (primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback), `Panel` (tones, `feature` corner brackets, polymorphic `as`) and `Eyebrow`, `Sheet` (modal: bottom sheet on phones, centred card from `sm`, renders its own `h2`), `Pill`, `Meter` (labelled `progressbar`), `StatTile`, `ScreenHeader` (the screen's `h1`), `Art`/`spriteUrl` (generated map sprites, 1x/2x `srcSet`, hides on error) and `Icon` (28 line icons). Test: `ui-kit.test.tsx`. - [x] **Step 10E.3 — App shell.** `Layout`: HUD header (logo mark, wordmark from 420 px, glowing credits chip, clock chip; `Credits` / `Game clock` labels kept) and icon navigation, a bottom bar on phones and a 96 px left rail from `md` (decision 0.5.49). `SCREENS` gained an `icon`. Attention badges from the pure `tabAttention(state)` (`layout/attention.ts`): HQ = goals ready, Jobs = idle crewed ships whose pilot isn't resting (1 during the tutorial until a run is in flight), Pilots = pilots with unspent skill points; badges are `aria-hidden`. `--header-h`/`--nav-h` CSS variables let the map fill exactly the space left on both layouts. `ui/Screen` is the content column (max 48 rem, rise-in). App loading, error and offline states use the kit. Test: `attention.test.tsx` (counts; badge doesn't change the tab's name); `layout.test.tsx` unchanged and passing. - [x] **Step 10E.4 — HQ dashboard.** `ScreenHeader` with the company name; icon stat tiles (2 × 2 on phones, 4 across from `md`); two columns from `md`. New `components/RouteStrip` (origin art → dashed line → destination art; with `progress` the line fills and the ship's art rides along; `aria-hidden`, place names drawn with CSS `content` so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; `GoalsCard` rows with check discs, reward amounts and a glowing Claim; `LoanCard` with an emerald `Meter` and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. **Manual check:** scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9). +- [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9).