From 9b45a239a5748e2d63b5f2ea260860650ab066d5 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 18:13:31 -0400 Subject: [PATCH] Step 11.2: offline shell behaviour Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 ++ apps/web/e2e/offline.e2e.ts | 139 +++++++++++++++++++++++ apps/web/src/App.tsx | 47 +++++--- apps/web/src/clock/clock.ts | 26 ++++- apps/web/src/components/DealModal.tsx | 1 + apps/web/src/components/GoalsCard.tsx | 1 + apps/web/src/components/LoanCard.tsx | 6 +- apps/web/src/screens/BackupSettings.tsx | 22 +++- apps/web/src/screens/CompanySettings.tsx | 12 +- apps/web/src/screens/DevTools.tsx | 11 +- apps/web/src/screens/DispatchSheet.tsx | 7 +- apps/web/src/screens/Fleet.tsx | 5 +- apps/web/src/screens/LocationBoards.tsx | 8 +- apps/web/src/screens/Locations.tsx | 1 + apps/web/src/screens/Market.tsx | 2 + apps/web/src/screens/Pilots.tsx | 7 +- apps/web/src/screens/ShipUpgrades.tsx | 1 + apps/web/src/screens/StartScreen.tsx | 1 + apps/web/src/screens/TutorialBoard.tsx | 1 + apps/web/src/state/actions.ts | 12 +- apps/web/src/state/connection.ts | 79 +++++++++++++ apps/web/src/state/player.ts | 34 +++++- apps/web/src/ui/Button.tsx | 16 ++- apps/web/test/fixtures.ts | 76 +++++++++++++ apps/web/test/offline.test.tsx | 73 ++++++++++++ apps/web/test/render.tsx | 75 +----------- apps/web/test/setup.ts | 3 + apps/web/test/states.test.tsx | 4 +- memory-bank/architecture.md | 9 +- memory-bank/progress.md | 4 +- 30 files changed, 570 insertions(+), 121 deletions(-) create mode 100644 apps/web/e2e/offline.e2e.ts create mode 100644 apps/web/src/state/connection.ts create mode 100644 apps/web/test/fixtures.ts create mode 100644 apps/web/test/offline.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 9c2b050..a8242fc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1248,3 +1248,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Icons are generated placeholders** (the Courier sprite on `#080d1d`), with the maskable icon keeping the ship inside the 80 % safe zone. Replace by putting a logo through `scripts/icons.mjs`. - **Precache is ~6.7 MB**, mostly the @2x map art the CR-003 amendment asks for. Only woff2 fonts are cached (every supporting browser uses them before woff). - **Auto-update**: a new build's service worker takes over on the next load without a prompt; there is no player data in the cache to lose. + +## Step 11.2 + +- **"Offline" means no reply, not an error reply.** A fetch that rejects (no network, server unreachable) switches to the cached state; a 4xx/5xx from the server is shown as an error as before, because the server is there. +- **The last player state is cached in local storage**, schema-checked on read (a build with a different shape ignores it) and cleared on sign-out. It's a convenience cache: if storage is blocked, an offline start shows "Could not reach the server" as before. +- **Actions are disabled per button (`needsServer`)**, not app-wide: navigation, closing sheets, reading details, the map and display/help settings keep working. Buttons that only open an action sheet (Dispatch, Deadhead, Buy, Rename, Back up, Restore, New company) are disabled too, so the player isn't led into a form they can't submit. Viewing a ship's upgrades stays open (it's informational; its Install button is disabled). +- **The away summary and the deal wait for the connection**: both exist to send something to the server, and a modal that can't be dismissed would lock the app offline. +- **The browser test fakes the API with Playwright route interception** against the real production build and service worker, so it needs no server or database; the end-to-end tests against a real server are Phase 12's. diff --git a/apps/web/e2e/offline.e2e.ts b/apps/web/e2e/offline.e2e.ts new file mode 100644 index 0000000..f82d8fc --- /dev/null +++ b/apps/web/e2e/offline.e2e.ts @@ -0,0 +1,139 @@ +import type { Dispatch, PlayerState } from "@deadhead/schema"; +import { expect, test, type Page } from "@playwright/test"; +import { samplePlayerState, shipExtras } from "../test/fixtures"; + +/** + * A company with one ship in flight (Earth → Moon, 20 minutes left) and one idle + * at Earth, as the server would return it. + */ +function midgame(now: number): PlayerState { + const base = samplePlayerState({ tutorial: null }); + const flight: Dispatch = { + id: "d1", + shipId: "s-fly", + pilotId: "p2", + contract: null, + originId: "earth", + destinationId: "moon", + tutorial: false, + departureAt: now - 60_000, + arrivalAt: now + 20 * 60_000, + distanceMillionKm: 0.38, + fuelCost: 160, + expectedPayout: 0, + seed: 1, + status: "in-flight", + resolvedAt: null, + }; + return { + ...base, + company: { id: "co1", name: "Deadhead Freight", credits: 50_000, loanBalance: 0 }, + bays: [{ id: "b1", locationId: "earth" }], + ships: [ + { + id: "s-idle", + modelId: "courier", + name: "Dusty Mule", + status: "idle", + locationId: "earth", + dispatchId: null, + pilotId: "avatar", + lastArrivalAt: 0, + ...shipExtras("courier"), + }, + { + id: "s-fly", + modelId: "clipper", + name: "Slow Ox", + status: "in-flight", + locationId: null, + dispatchId: "d1", + pilotId: "p2", + lastArrivalAt: 0, + ...shipExtras("clipper"), + }, + ], + pilots: [ + { ...base.pilots[0]!, assignedShipId: "s-idle" }, + { + ...base.pilots[0]!, + id: "p2", + name: "Rae Okafor", + isAvatar: false, + assignedShipId: "s-fly", + status: "in-flight", + locationId: null, + }, + ], + activeDispatches: [flight], + }; +} + +/** Stands in for the API server; with `offline` set, every request fails as if unplugged. */ +async function fakeServer(page: Page) { + const server = { offline: false, state: midgame(Date.now()) }; + await page.route("**/api/**", async (route) => { + if (server.offline) return route.abort("internetdisconnected"); + const path = new URL(route.request().url()).pathname; + if (path === "/api/state") return route.fulfill({ json: server.state }); + if (path === "/api/time") return route.fulfill({ json: { realTime: Date.now(), gameTime: 0 } }); + if (path.endsWith("/board")) { + return route.fulfill({ json: { contracts: [], periodEndsAt: Date.now() + 600_000 } }); + } + return route.fulfill({ + status: 404, + json: { error: { code: "not-found", message: "Not in this test" } }, + }); + }); + return server; +} + +const progress = async (page: Page) => + Number(await page.getByRole("progressbar").first().getAttribute("aria-valuenow")); + +test.describe("offline shell (Step 11.2)", () => { + test("opens from the cache with the last state, keeps flights moving, and blocks actions", async ({ + page, + context, + }) => { + const server = await fakeServer(page); + await page.goto("/fleet"); + await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible(); + await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined)); + await expect(page.getByRole("button", { name: /Deadhead/ })).toBeEnabled(); + + // Network off, then a full reload: everything must come from the device. + server.offline = true; + await context.setOffline(true); + await page.reload(); + + await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible(); + await expect(page.getByText(/Offline — showing last update from/)).toBeVisible(); + await expect(page.getByText("Slow Ox")).toBeVisible(); + + // The flight's progress bar keeps advancing from the cached dispatch. + const before = await progress(page); + await expect.poll(() => progress(page), { timeout: 10_000 }).toBeGreaterThan(before); + + // Server actions are disabled; reading and navigating still work. + await expect(page.getByRole("button", { name: /Deadhead/ })).toBeDisabled(); + await page.getByRole("link", { name: "Shop" }).first().click(); + await expect(page.getByRole("button", { name: "Buy Courier" })).toBeDisabled(); + + // Settings (CR-011): server-backed ones off, display ones still work. + await page.goto("/settings").catch(() => undefined); + await expect(page.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible(); + for (const name of ["Rename", "Back up company", "Restore a company", "Start a new company"]) { + await expect(page.getByRole("button", { name })).toBeDisabled(); + } + const clock24 = page.getByRole("radio", { name: "24-hour" }); + await page.getByText("24-hour", { exact: true }).click(); + await expect(clock24).toBeChecked(); + + // Back online: the banner goes and actions return. + server.offline = false; + await context.setOffline(false); + await expect(page.getByText(/Offline — showing last update/)).toHaveCount(0); + await expect(page.getByRole("button", { name: "Rename" })).toBeEnabled(); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3bcf6be..d822237 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, type ReactNode } from "react"; +import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react"; import { Route, Routes } from "react-router"; import { useApi } from "./api/context"; import { startClockTicker, syncClock } from "./clock/clock"; @@ -15,7 +15,9 @@ import { Locations } from "./screens/Locations"; import { Market } from "./screens/Market"; import { Pilots } from "./screens/Pilots"; import { Settings } from "./screens/Settings"; -import { useMotionPreference } from "./state/preferences"; +import { useConnection, useConnectionEvents } from "./state/connection"; +import { usePreferences, useMotionPreference } from "./state/preferences"; +import { formatTimeOfDay } from "./clock/timeFormat"; import { StartScreen } from "./screens/StartScreen"; import { usePlayerState, useRefetchOnArrival } from "./state/player"; import { Button } from "./ui"; @@ -54,7 +56,13 @@ export function App() { useMotionPreference(); const state = usePlayerState(); useRefetchOnArrival(state.data ?? null, state.refetch); - const retry = () => void state.refetch(); + const { refetch } = state; + const retry = () => void refetch(); + // Back online: refresh at once (Step 11.2). + useConnectionEvents(useCallback(() => void refetch(), [refetch])); + const online = useConnection((s) => s.online); + const lastUpdate = useConnection((s) => s.lastUpdate); + const clockFormat = usePreferences((p) => p.clock); if (state.isPending) { return ( @@ -76,18 +84,22 @@ export function App() { } if (state.data === null) return ; - // A later refresh failed: keep showing the last state, with a way to retry. - const banner = state.isError ? ( -
- Offline — showing last update. - -
- ) : null; + // Offline, or a later refresh failed: keep showing the last state (Step 11.2). + const banner = + !online || state.isError ? ( +
+ + Offline — showing last update + {lastUpdate !== null && ` from ${formatTimeOfDay(lastUpdate, clockFormat)}`}. + + +
+ ) : null; return ( <> @@ -113,8 +125,9 @@ export function App() { } /> - - + {/* Both exist to send something to the server: wait until back online. */} + {online && } + {online && } ); } diff --git a/apps/web/src/clock/clock.ts b/apps/web/src/clock/clock.ts index c9136a4..b39fcd1 100644 --- a/apps/web/src/clock/clock.ts +++ b/apps/web/src/clock/clock.ts @@ -10,11 +10,22 @@ interface ClockState { now: RealTime; } -export const useClock = create(() => ({ - offsetMs: 0, - synced: false, - now: Date.now(), -})); +const OFFSET_KEY = "deadhead:clock-offset"; + +/** The last synced offset, so an offline start still shows server time (Step 11.2). */ +function savedOffset(): number { + try { + const value = Number(localStorage.getItem(OFFSET_KEY)); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +} + +export const useClock = create(() => { + const offsetMs = savedOffset(); + return { offsetMs, synced: false, now: Date.now() + offsetMs }; +}); /** The current time on the server's clock: device time plus the synced offset. */ export function serverNow(): RealTime { @@ -31,6 +42,11 @@ export async function syncClock(api: ApiClient): Promise { const receivedAt = Date.now(); const offsetMs = realTime - (sentAt + receivedAt) / 2; useClock.setState({ offsetMs, synced: true, now: Date.now() + offsetMs }); + try { + localStorage.setItem(OFFSET_KEY, String(offsetMs)); + } catch { + // Storage blocked: an offline start uses the device clock. + } } /** Updates `now` every second. Returns a function that stops the ticker. */ diff --git a/apps/web/src/components/DealModal.tsx b/apps/web/src/components/DealModal.tsx index b547a84..0b034db 100644 --- a/apps/web/src/components/DealModal.tsx +++ b/apps/web/src/components/DealModal.tsx @@ -85,6 +85,7 @@ export function DealModal() {

)} diff --git a/apps/web/src/screens/BackupSettings.tsx b/apps/web/src/screens/BackupSettings.tsx index dcd6b29..642e1a8 100644 --- a/apps/web/src/screens/BackupSettings.tsx +++ b/apps/web/src/screens/BackupSettings.tsx @@ -67,7 +67,13 @@ export function BackupCompany() {

Your old code will stop working as soon as you generate a new one.

- @@ -79,7 +85,13 @@ export function BackupCompany() { A backup code lets you open this company on any device, or get it back if this one is lost. No email or password.

- @@ -88,7 +100,7 @@ export function BackupCompany() { return ( <> - {open && ( @@ -130,7 +142,7 @@ export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) { return ( <> - {open && ( @@ -169,7 +181,7 @@ export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) {

)}
- @@ -60,7 +65,7 @@ export function CompanyName() {

)}
- {open && ( @@ -110,6 +115,7 @@ export function NewCompany() { )}
- -
diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index 64df130..d147488 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -16,6 +16,7 @@ import { strandedText } from "./DispatchSheet"; import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format"; import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; +import { useOnline } from "../state/connection"; import { Art, Button, @@ -160,6 +161,7 @@ function ShipRow(props: { )} )} @@ -350,6 +355,7 @@ function NothingHere({

+ + , + ); + expect(screen.getByRole("button", { name: "Buy" })).toBeEnabled(); + act(() => useConnection.setState({ online: false })); + expect(screen.getByRole("button", { name: "Buy" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Buy" })).toHaveAttribute( + "title", + "Unavailable offline", + ); + expect(screen.getByRole("button", { name: "Close" })).toBeEnabled(); + }); +}); diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index 092e992..0114f92 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -1,6 +1,4 @@ -import { content } from "@deadhead/content"; -import type { PlayerState, RouteName, RouteRequest, RouteResponse, Ship } from "@deadhead/schema"; -import { effectiveStats } from "@deadhead/sim"; +import type { RouteName, RouteRequest, RouteResponse } from "@deadhead/schema"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "@testing-library/react"; import { MemoryRouter } from "react-router"; @@ -33,76 +31,7 @@ export const unauthorized = () => { throw new ApiError(401, "unauthorized", "Sign in to continue."); }; -/** A brand-new tutorial player, as guest sign-up returns it. */ -/** - * The CR-009 fields of a ship with no upgrades and not in the shop: its model's - * stats as effective stats. Spread into Ship fixtures, then override as needed. - */ -export function shipExtras( - modelId: string, - upgrades: Record = {}, -): Pick { - const model = content.shipModel(modelId); - if (!model) throw new Error(`Unknown ship model ${modelId}`); - const s = effectiveStats(model, upgrades, content); - return { - upgrades, - stats: { - cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour, - cargoCapacityTonnes: s.cargoCapacityTonnes, - fuelRangeMillionKm: s.fuelRangeMillionKm, - overheadGameHours: s.overheadGameHours, - handlingGameHoursPerTonne: s.handlingGameHoursPerTonne, - }, - inShopUntil: null, - shop: null, - }; -} - -export function samplePlayerState(overrides: Partial = {}): PlayerState { - return { - playerId: "player-1", - company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 }, - ships: [], - pilots: [ - { - id: "avatar", - name: "Captain", - isAvatar: true, - isVeteran: false, - xp: 0, - level: 0, - skills: {}, - skillPointsAvailable: 0, - assignedShipId: null, - status: "idle", - locationId: "earth", - dutyHours: 0, - dutyLimit: 12, - resting: false, - restEndsAt: null, - }, - ], - bays: [], - locations: [ - { id: "earth", discovered: true }, - { id: "moon", discovered: true }, - { id: "mars", discovered: false }, - { id: "ceres-station", discovered: false }, - { id: "jupiter-cloud-platform", discovered: false }, - ], - tutorial: { - loanerLocationId: "earth", - runInFlight: false, - runsCompleted: 0, - dealAvailable: false, - }, - activeDispatches: [], - summaries: [], - goals: [], - ...overrides, - }; -} +export { samplePlayerState, shipExtras } from "./fixtures"; /** Renders the whole app at `path` with a fresh query cache and the given API. */ export function renderApp(api: ApiClient, path = "/") { diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts index 047c035..7ccdce6 100644 --- a/apps/web/test/setup.ts +++ b/apps/web/test/setup.ts @@ -1,11 +1,14 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, configure } from "@testing-library/react"; import { afterEach, vi } from "vitest"; +import { useConnection } from "../src/state/connection"; afterEach(() => { cleanup(); // The board remembers what was seen per location (CR-008); start each test fresh. if (typeof localStorage !== "undefined") localStorage.clear(); + // Offline in one test must not leak into the next (Step 11.2). + useConnection.setState({ online: true, lastUpdate: null }); }); // findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s. diff --git a/apps/web/test/states.test.tsx b/apps/web/test/states.test.tsx index 77811e6..6aa709f 100644 --- a/apps/web/test/states.test.tsx +++ b/apps/web/test/states.test.tsx @@ -52,13 +52,13 @@ describe("network errors", () => { focusManager.setFocused(false); focusManager.setFocused(true); }); - const banner = await screen.findByText("Offline — showing last update."); + const banner = await screen.findByText(/Offline — showing last update/); expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument(); online = true; await userEvent.click(within(banner.parentElement!).getByRole("button", { name: "Retry" })); await act(async () => {}); - expect(screen.queryByText("Offline — showing last update.")).not.toBeInTheDocument(); + expect(screen.queryByText(/Offline — showing last update/)).not.toBeInTheDocument(); }, ); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 2980a66..09f78fe 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -460,7 +460,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen |---|---| | `src/api/client.ts` | `createApiClient().call(route, input)`: typed by the route catalog; validates responses; throws `ApiError` (server code) or `ApiValidationError` | | `src/api/context.tsx` | `ApiProvider` / `useApi` (tests inject a fake) | -| `src/clock/clock.ts` | Zustand `useClock`: server offset, `serverNow()`, 1 s ticker | +| `src/clock/clock.ts` | Zustand `useClock`: server offset (last one kept in `deadhead:clock-offset` for offline starts), `serverNow()`, 1 s ticker | | `src/state/player.ts` | `usePlayerState` (TanStack Query, key `player-state`): `null` means no session; refetch on focus and on arrival | | `src/App.tsx` | Clock sync; loading / start screen / routed layout | | `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | @@ -473,6 +473,9 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen | `src/map/overlays.ts` | Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer | | `src/map/ticker.ts` | `runWhileVisible`: pause the map's ticker while the page is hidden; stop it on unmount | | `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state | +| `src/state/connection.ts` | Offline (Step 11.2): Zustand `useConnection` (`online`, `lastUpdate`), browser online/offline events, and the last player state cache (`deadhead:last-state`, schema-checked on load) | +| `vite.config.ts` (PWA) | `vite-plugin-pwa` (Step 11.1): manifest, generateSW precache of the shell, woff2 fonts, `map-gen` art and icons (`public/icons`, from `scripts/icons.mjs`); `/api` never cached | +| `e2e/*.e2e.ts` | Playwright tests against `vite preview` of the build (install, offline); the API is faked with route interception | | `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera; local storage, every access wrapped | | `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen | | `src/components/*` | `AwaySummary` dialog, `Countdown` | @@ -480,4 +483,6 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen | `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) | | `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting | -Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself. +Session: Better Auth's cookie (`credentials: "include"`). On the device the client keeps only caches and preferences: the last player state and clock offset (offline start), device preferences, the device id and seen board ids. + +Offline (Step 11.2): a request that gets no reply marks the app offline and `getPlayerState` falls back to the cached state; the banner reads "Offline — showing last update from HH:MM". Buttons with `needsServer` (every server action and the sheets that lead to one) are disabled while offline; the away summary and the deal wait until back online. The browser's `online` event refetches. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c336cfb..f66d84c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -300,4 +300,6 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR- ## Phase 11 — PWA - [x] **Step 11.1 — Installable PWA.** `vite-plugin-pwa` 2.0 (generateSW, auto-update): manifest "Deadhead: Space Freight Tycoon" / "Deadhead", standalone, theme and background `#080d1d`, icons 192, 512 and maskable 512 (plus favicon and Apple touch icon in `index.html`). The service worker precaches the app shell, the self-hosted font files (woff2) and all 25 generated map images (64 entries, ~6.7 MB); source art in `assets/map/` is excluded; `/api` is never served from the cache. Icons come from `scripts/icons.mjs` (`pnpm --filter @deadhead/web icons`): the Courier sprite on the space background, placeholder until there is a logo. Playwright is set up for browser tests against the production build (`apps/web/playwright.config.ts`, `e2e/*.e2e.ts`, `pnpm --filter @deadhead/web e2e`), and CI builds the web app, installs Chromium and runs them. Tests (`e2e/install.e2e.ts`): Chrome reports no installability errors; the manifest has the name, standalone display, both icon sizes and a maskable icon, all served; the precache holds index.html, map-gen art and both fonts, and no source art. **Manual check for the user:** install to a phone home screen and launch standalone. -- [ ] Step 11.2 — Offline shell behavior +- [x] **Step 11.2 — Offline shell behavior.** A request that gets no reply (or the browser's `offline` event) marks the app offline (`state/connection.ts`). Each fetched player state is kept on the device (schema-checked when read back) and returned when the server can't be reached, so a reload offline opens from the service worker cache with the last state; the last clock offset is kept too. The banner reads "Offline — showing last update from HH:MM" with Retry; the browser's `online` event refetches and clears it. `Button` gains `needsServer`, set on every server action and the buttons that open action sheets (dispatch, deadhead, buy, hire, lease, assign, rest, goals, loan, deal, tutorial runs, and the CR-011 rename, backup, restore, new company and developer tools); the two plain buttons (deadhead destinations, raise skill) check `useOnline`. The away summary and deal modal wait until back online. Display and help settings still work. Shared fixtures moved to `test/fixtures.ts` for Playwright. Tests: `e2e/offline.e2e.ts` (fake API by route interception; network off and reload: Fleet renders from cache with the banner and the in-flight ship, its progress bar advances, Deadhead and Buy are disabled, server-backed settings are disabled while the clock format still changes; back online the banner goes and actions return) and `test/offline.test.tsx` (cache fallback only on no reply, not on an error reply; first load offline with no cache still fails; cache cleared on sign-out and ignored if malformed; `needsServer` buttons disabled only offline). + +**Phase 11 complete — waiting for review.**