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:
parent
621eecb737
commit
bc29c515eb
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()}>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue