Step 10K.4: reset map view and show tips again

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 23:21:39 -04:00
parent 621eecb737
commit bc29c515eb
7 changed files with 100 additions and 14 deletions

View File

@ -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. - **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). - **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`). - **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).

View File

@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { usePreferences } from "../state/preferences";
import type { MapPoint } from "./projection"; import type { MapPoint } from "./projection";
/** Pan (pixels) and zoom (1 = whole system fits) of the map view. */ /** 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 DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 };
export const useCamera = create<Camera>(() => DEFAULT_CAMERA); /** The map view, starting where the player left it on this device (CR-011). */
export const useCamera = create<Camera>(
() => usePreferences.getState().mapCamera ?? DEFAULT_CAMERA,
);
// Save the view as it changes, at most a few times a second.
let saveTimer: ReturnType<typeof setTimeout> | 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. */ /** Pixels per map unit at this zoom. */
function pixelsPerUnit(viewport: Viewport, camera: Camera): number { function pixelsPerUnit(viewport: Viewport, camera: Camera): number {

View File

@ -7,7 +7,7 @@ import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { cargoName, formatMillionKm, locationName } from "../format"; import { cargoName, formatMillionKm, locationName } from "../format";
import { distanceAt } from "../game"; import { distanceAt } from "../game";
import { DEFAULT_CAMERA, useCamera } from "../map/camera"; import { resetMapView } from "../map/camera";
import type { MapSelection } from "../map/hitTest"; import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas"; import { MapCanvas } from "../map/MapCanvas";
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach"; import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
@ -36,7 +36,7 @@ export function MapScreen() {
<MapCanvas state={state} onSelect={setSelection} selected={selection} /> <MapCanvas state={state} onSelect={setSelection} selected={selection} />
<button <button
type="button" type="button"
onClick={() => useCamera.setState(DEFAULT_CAMERA)} onClick={resetMapView}
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`} className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
> >
Reset view Reset view

View File

@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { DEFAULT_CAMERA, useCamera } from "../map/camera"; import { resetMapView } from "../map/camera";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences"; import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
import { Button, Panel, Screen, ScreenHeader } from "../ui"; import { Button, Panel, Screen, ScreenHeader } from "../ui";
@ -57,14 +57,7 @@ export function Settings() {
<Section title="Help"> <Section title="Help">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button variant="secondary" size="sm" onClick={resetMapView}>
variant="secondary"
size="sm"
onClick={() => {
useCamera.setState(DEFAULT_CAMERA);
prefs.set({ mapCamera: null });
}}
>
Reset map view Reset map view
</Button> </Button>
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}> <Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>

View File

@ -7,11 +7,15 @@ import { formatCredits, formatDuration, locationName } from "../format";
import { goodName } from "../components/Goods"; import { goodName } from "../components/Goods";
import { distanceAt, tripRealMs } from "../game"; import { distanceAt, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { RouteStrip } from "../components/RouteStrip"; import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Icon, Panel, Pill } from "../ui"; import { Art, Button, Icon, Panel, Pill } from "../ui";
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const; export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
/** The id of the tutorial intro card in dismissed tips. */
export const INTRO_TIP = "tutorial-intro";
/** The company board during the tutorial (Step 9.3). */ /** The company board during the tutorial (Step 9.3). */
export function TutorialBoard() { export function TutorialBoard() {
const api = useApi(); const api = useApi();
@ -25,6 +29,9 @@ export function TutorialBoard() {
const runInFlight = state.tutorial?.runInFlight ?? false; const runInFlight = state.tutorial?.runInFlight ?? false;
const loaner = loanerModel(content); const loaner = loanerModel(content);
const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0; const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0;
// A one-time tip: dismissible, and back with "Show tips again" (CR-011).
const introDismissed = usePreferences((p) => p.dismissedTips.includes(INTRO_TIP));
const dismissTip = usePreferences((p) => p.dismissTip);
if (board.isPending) return <p className="p-4 text-ink-400">Loading contracts…</p>; if (board.isPending) return <p className="p-4 text-ink-400">Loading contracts…</p>;
if (board.isError) { if (board.isError) {
@ -40,7 +47,7 @@ export function TutorialBoard() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{firstRun && ( {firstRun && !introDismissed && (
<Panel aria-label="Intro" feature tone="amber" className="flex items-center gap-3 text-sm"> <Panel aria-label="Intro" feature tone="amber" className="flex items-center gap-3 text-sm">
<Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" /> <Art sprite={loaner.sprite} className="size-16 shrink-0 -rotate-12" />
<div> <div>
@ -49,6 +56,14 @@ export function TutorialBoard() {
</p> </p>
<p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p> <p className="text-ink-200">Their ship, your hands. Get the crates to the Moon.</p>
</div> </div>
<button
type="button"
aria-label="Dismiss tip"
onClick={() => dismissTip(INTRO_TIP)}
className="ml-auto self-start rounded-md p-1 text-ink-400 hover:text-ink-100"
>
<Icon name="close" className="size-4" />
</button>
</Panel> </Panel>
)} )}
<p className="text-sm text-ink-300"> <p className="text-sm text-ink-300">

View File

@ -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();
});
});

View File

@ -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.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.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 `<html data-reduced-motion>`, 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. - [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 `<html data-reduced-motion>`, 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.5 — Server: rename company
- [ ] Step 10K.6 — Server: start a new company - [ ] Step 10K.6 — Server: start a new company
- [ ] Step 10K.7 — Client: rename and new company flows - [ ] Step 10K.7 — Client: rename and new company flows