From bc29c515eb82ee8e5b6ee289949f9d6003f50408 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 23:21:39 -0400 Subject: [PATCH] Step 10K.4: reset map view and show tips again Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 +++ apps/web/src/map/camera.ts | 24 +++++++++++- apps/web/src/screens/MapScreen.tsx | 4 +- apps/web/src/screens/Settings.tsx | 11 +----- apps/web/src/screens/TutorialBoard.tsx | 17 ++++++++- apps/web/test/help-settings.test.tsx | 51 ++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 7 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 apps/web/test/help-settings.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 6be833c..35fb8fe 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1065,3 +1065,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **User report:** "On a phone screen the credits and time line can't be read" (screenshot at about 430 px): the credits chip was drawn over the DEADHEAD wordmark. The wordmark showed from 420 px, and 10K.2's gear plus 10K.3's device-locale clock ("2145-07-11 7:18 PM") made the right side wider than the space left. - **Fix:** the wordmark now shows from 640 px (`sm`), the logo side clips instead of overflowing, the game clock shows only the time below 640 px (the date returns above), chips sit a little closer, and the gear is 32 px on phones. The gear icon is now a cog (it read as a sun). - **Checked** in headless Chrome at 375, 390 and 430 px with a 1,234,567 cr balance and a 12-hour clock: no overlapping header items and no horizontal scroll (`docs/screenshots/header-phone-390.png`). + +## Step 10K.4 + +- **The map now remembers its view per device** (the CR's "saved map camera"), saving at most every 300 ms while panning; "Reset map view" clears it. +- **The intro card became the first dismissible tip**: it still shows only before the first tutorial run, but can be closed, and "Show tips again" (or a new company) brings it back. Other hints stay state-driven (they disappear on their own when no longer true). diff --git a/apps/web/src/map/camera.ts b/apps/web/src/map/camera.ts index 6fc552d..f9ede55 100644 --- a/apps/web/src/map/camera.ts +++ b/apps/web/src/map/camera.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { usePreferences } from "../state/preferences"; import type { MapPoint } from "./projection"; /** Pan (pixels) and zoom (1 = whole system fits) of the map view. */ @@ -20,7 +21,28 @@ const FIT = 0.92; export const DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 }; -export const useCamera = create(() => DEFAULT_CAMERA); +/** The map view, starting where the player left it on this device (CR-011). */ +export const useCamera = create( + () => usePreferences.getState().mapCamera ?? DEFAULT_CAMERA, +); + +// Save the view as it changes, at most a few times a second. +let saveTimer: ReturnType | null = null; +useCamera.subscribe((camera) => { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + saveTimer = null; + usePreferences.getState().set({ mapCamera: camera }); + }, 300); +}); + +/** Back to the default view, and forget the saved one ("Reset map view"). */ +export function resetMapView(): void { + useCamera.setState(DEFAULT_CAMERA); + if (saveTimer) clearTimeout(saveTimer); + saveTimer = null; + usePreferences.getState().set({ mapCamera: null }); +} /** Pixels per map unit at this zoom. */ function pixelsPerUnit(viewport: Viewport, camera: Camera): number { diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index 7ce3209..1b41e18 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -7,7 +7,7 @@ import { formatGameTime } from "../clock/GameClock"; import { Countdown } from "../components/Countdown"; import { cargoName, formatMillionKm, locationName } from "../format"; import { distanceAt } from "../game"; -import { DEFAULT_CAMERA, useCamera } from "../map/camera"; +import { resetMapView } from "../map/camera"; import type { MapSelection } from "../map/hitTest"; import { MapCanvas } from "../map/MapCanvas"; import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach"; @@ -36,7 +36,7 @@ export function MapScreen() { )}

diff --git a/apps/web/test/help-settings.test.tsx b/apps/web/test/help-settings.test.tsx new file mode 100644 index 0000000..5984725 --- /dev/null +++ b/apps/web/test/help-settings.test.tsx @@ -0,0 +1,51 @@ +import { act, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_CAMERA, pan, useCamera } from "../src/map/camera"; +import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences"; +import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render"; + +describe("help settings (Step 10K.4)", () => { + afterEach(() => { + vi.useRealTimers(); + act(() => { + useCamera.setState(DEFAULT_CAMERA); + usePreferences.getState().set(DEFAULT_PREFERENCES); + }); + }); + + it("saves the map view, and Reset map view restores the default and forgets it", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + act(() => useCamera.setState((c) => pan({ ...c, zoom: 3 }, 120, -80))); + act(() => vi.advanceTimersByTime(400)); + expect(usePreferences.getState().mapCamera).toEqual({ zoom: 3, panX: 120, panY: -80 }); + vi.useRealTimers(); + + renderApp( + fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }), + "/settings", + ); + await userEvent.click(await screen.findByRole("button", { name: "Reset map view" })); + expect(useCamera.getState()).toEqual(DEFAULT_CAMERA); + expect(usePreferences.getState().mapCamera).toBeNull(); + }); + + it("brings a dismissed tip back with Show tips again", async () => { + renderApp( + fakeApi({ + getPlayerState: () => samplePlayerState(), + getTutorialBoard: () => sampleTutorialBoard(), + }), + "/contracts", + ); + const intro = await screen.findByLabelText("Intro"); + await userEvent.click(within(intro).getByRole("button", { name: "Dismiss tip" })); + expect(screen.queryByLabelText("Intro")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("link", { name: "Settings" })); + await userEvent.click(await screen.findByRole("button", { name: "Show tips again" })); + const nav = screen.getByRole("navigation", { name: "Main" }); + await userEvent.click(within(nav).getByRole("link", { name: /Jobs/ })); + expect(await screen.findByLabelText("Intro")).toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 35efb6d..42436d7 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -255,7 +255,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0 - [x] **Step 10K.1 — Client: device preferences store.** `state/preferences.ts`: `usePreferences` (Zustand) with clock (`locale`/`12h`/`24h`), countdown (`real`/`game`/`both`), reduced-motion override (null = follow the device), dismissed tips and saved map camera; `set`, `dismissTip`, `showTipsAgain`. Saved under `deadhead:preferences:v1`; loading validates each field and falls back to defaults; every read and write is wrapped. `reducedMotionOn(prefs)`. Test: `preferences.test.ts` (defaults incl. mocked OS reduced motion, survives a reload, broken data ignored, throwing storage works in memory). - [x] **Step 10K.2 — Client: settings screen and entry point.** A gear link (`aria-label="Settings"`, new `gear` icon) at the right of the header opens `/settings` (a route inside the layout, not a nav tab). `screens/Settings.tsx`: sections Company (name), Display (Clock: Device/12-hour/24-hour; Countdowns: Real time/Game time/Both; Reduced motion: Device/On/Off, saved to device preferences), Help (Reset map view, Show tips again), About (game name) and Developer, which renders only when `import.meta.env.PROD` is false. Remaining items arrive with their steps. Test: `settings.test.tsx`. - [x] **Step 10K.3 — Client: display options applied everywhere.** `clock/timeFormat.ts`: `formatTimeOfDay` and `formatGameTime` follow the clock preference (header and HQ game clock, dispatch ETAs, map flight panel, "resting until"); `formatSpan` / `formatGameHoursSpan` follow the countdown preference ("4m 16s", "4.3 game h" or both) and drive `Countdown` (arrivals, rest, shop, board refresh, contract expiry) and the Priority chip, trip and too-slow texts. Reduced motion sets ``, which the stylesheet uses alongside the OS query, and `mapMotionStyle` zeroes the map's parallax and zoom follow. The Priority chip no longer toggles on tap (it follows the preference; its tooltip shows both). Test: `display-options.test.tsx`; clock, layout, dispatch and contract-card tests updated for the new defaults. -- [ ] Step 10K.4 — Client: reset map view and show tips again +- [x] **Step 10K.4 — Client: reset map view and show tips again.** The map camera starts from the saved view and saves itself (throttled to 300 ms) as the player pans and zooms; `resetMapView()` restores the default and clears the saved view (Settings and the map's "Reset view"). The tutorial intro card is now a one-time tip (`tutorial-intro`) with a dismiss button; "Show tips again" clears dismissed tips. Test: `help-settings.test.tsx`. - [ ] Step 10K.5 — Server: rename company - [ ] Step 10K.6 — Server: start a new company - [ ] Step 10K.7 — Client: rename and new company flows