From 74cc29c8e3a22f85181a596cec9f1f4d1b7569d9 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 23:01:02 -0400 Subject: [PATCH] Step 10K.2: settings screen behind a header gear Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/web/src/App.tsx | 3 + apps/web/src/layout/Layout.tsx | 9 +- apps/web/src/screens/Settings.tsx | 140 ++++++++++++++++++++++++++++++ apps/web/src/ui/icons.tsx | 6 ++ apps/web/test/settings.test.tsx | 39 +++++++++ memory-bank/progress.md | 2 +- 7 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/screens/Settings.tsx create mode 100644 apps/web/test/settings.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index ffd11fd..c7ac03d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1045,3 +1045,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Clock default is `locale`** (the device's own 12/24-hour habit) rather than a fixed format. - **Saved data is validated field by field** on load; anything unknown or broken falls back to that field's default instead of discarding everything. - **The store is built by a factory** (`createPreferencesStore(storage)`) so tests can simulate a reload or a throwing storage; the app uses one instance on `localStorage`. + +## Step 10K.2 + +- **Settings is a route (`/settings`) inside the normal layout**, reached only from the header gear, so the seven-tab navigation is unchanged and the back button works. +- **Reduced motion is a three-way choice (Device / On / Off)**: "Device" stores no override, so the default keeps following the OS (decision 0.5.76). +- **Items appear with the step that makes them work** (rename and new company, backup, version, developer tools), so no placeholder controls ship. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index dc788e4..639de91 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,7 @@ import { HQ } from "./screens/HQ"; import { Locations } from "./screens/Locations"; import { Market } from "./screens/Market"; import { Pilots } from "./screens/Pilots"; +import { Settings } from "./screens/Settings"; import { StartScreen } from "./screens/StartScreen"; import { usePlayerState, useRefetchOnArrival } from "./state/player"; import { Button } from "./ui"; @@ -106,6 +107,8 @@ export function App() { element={SCREEN_COMPONENTS[screen.path]} /> ))} + {/* Settings opens from the header gear, not a tab (CR-011). */} + } /> diff --git a/apps/web/src/layout/Layout.tsx b/apps/web/src/layout/Layout.tsx index 3519c70..2a33897 100644 --- a/apps/web/src/layout/Layout.tsx +++ b/apps/web/src/layout/Layout.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { NavLink, Outlet } from "react-router"; +import { Link, NavLink, Outlet } from "react-router"; import { GameClock } from "../clock/GameClock"; import { SCREENS, type ScreenPath } from "../routes"; import { Icon } from "../ui"; @@ -50,6 +50,13 @@ export function Layout({ {clock} + + + {banner} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx new file mode 100644 index 0000000..894a7ae --- /dev/null +++ b/apps/web/src/screens/Settings.tsx @@ -0,0 +1,140 @@ +import type { ReactNode } from "react"; +import { DEFAULT_CAMERA, useCamera } from "../map/camera"; +import { useLoadedPlayer } from "../state/actions"; +import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences"; +import { Button, Panel, Screen, ScreenHeader } from "../ui"; + +/** Developer tools only exist in non-production builds (CR-011). */ +export function isDevBuild(): boolean { + return !import.meta.env.PROD; +} + +/** Settings (CR-011): company, display, help, about and, in development, developer tools. */ +export function Settings() { + const state = useLoadedPlayer(); + const prefs = usePreferences(); + + return ( + + + +
+ {state.company.name} +
+ +
+ + label="Clock" + value={prefs.clock} + options={[ + ["locale", "Device"], + ["12h", "12-hour"], + ["24h", "24-hour"], + ]} + onChange={(clock) => prefs.set({ clock })} + /> + + label="Countdowns" + value={prefs.countdown} + options={[ + ["real", "Real time"], + ["game", "Game time"], + ["both", "Both"], + ]} + onChange={(countdown) => prefs.set({ countdown })} + /> + + label="Reduced motion" + value={prefs.reducedMotion === null ? "device" : prefs.reducedMotion ? "on" : "off"} + options={[ + ["device", "Device"], + ["on", "On"], + ["off", "Off"], + ]} + onChange={(v) => prefs.set({ reducedMotion: v === "device" ? null : v === "on" })} + /> +
+ +
+
+ + +
+
+ +
+ Deadhead: Space Freight Tycoon +
+ + {isDevBuild() && ( +
+

Development build tools.

+
+ )} +
+ ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + const id = `settings-${title.toLowerCase()}`; + return ( + +

+ {title} +

+ {children} +
+ ); +} + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** A labelled radio group of a few options, styled as a segmented control. */ +function Choice(props: { + label: string; + value: T; + options: [T, string][]; + onChange: (value: T) => void; +}) { + return ( +
+ {props.label} +
+ {props.options.map(([value, text]) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/ui/icons.tsx b/apps/web/src/ui/icons.tsx index a6c6fad..8c4dc54 100644 --- a/apps/web/src/ui/icons.tsx +++ b/apps/web/src/ui/icons.tsx @@ -82,6 +82,12 @@ const PATHS = { ), close: , + gear: ( + <> + + + + ), plus: , target: ( <> diff --git a/apps/web/test/settings.test.tsx b/apps/web/test/settings.test.tsx new file mode 100644 index 0000000..6f22241 --- /dev/null +++ b/apps/web/test/settings.test.tsx @@ -0,0 +1,39 @@ +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const owner = () => samplePlayerState({ tutorial: null }); + +describe("settings screen (Step 10K.2)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + usePreferences.getState().set(DEFAULT_PREFERENCES); + }); + + it("opens from the header gear with every section", async () => { + renderApp(fakeApi({ getPlayerState: owner }), "/"); + await userEvent.click(await screen.findByRole("link", { name: "Settings" })); + expect(await screen.findByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument(); + for (const section of ["Company", "Display", "Help", "About", "Developer"]) { + expect(screen.getByRole("region", { name: section }), section).toBeInTheDocument(); + } + }); + + it("leaves out the Developer section in a production build", async () => { + vi.stubEnv("PROD", true); + renderApp(fakeApi({ getPlayerState: owner }), "/settings"); + await screen.findByRole("region", { name: "Display" }); + expect(screen.queryByRole("region", { name: "Developer" })).not.toBeInTheDocument(); + }); + + it("saves display choices to the device preferences", async () => { + renderApp(fakeApi({ getPlayerState: owner }), "/settings"); + const display = await screen.findByRole("region", { name: "Display" }); + await userEvent.click(within(display).getByRole("radio", { name: "24-hour" })); + await userEvent.click(within(display).getByRole("radio", { name: "Both" })); + expect(usePreferences.getState()).toMatchObject({ clock: "24h", countdown: "both" }); + expect(within(display).getByRole("radio", { name: "24-hour" })).toBeChecked(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 66419ab..b1490de 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -253,7 +253,7 @@ Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (receiv Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-008 … Phase 10H"). New decisions recorded as plan §0.5.71–0.5.76. - [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). -- [ ] Step 10K.2 — Client: settings screen and entry point +- [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`. - [ ] Step 10K.3 — Client: display options applied everywhere - [ ] Step 10K.4 — Client: reset map view and show tips again - [ ] Step 10K.5 — Server: rename company