Step 10K.2: settings screen behind a header gear

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 23:01:02 -04:00
parent c73c9a4116
commit 74cc29c8e3
7 changed files with 203 additions and 2 deletions

View File

@ -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.

View File

@ -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). */}
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
<AwaySummary />

View File

@ -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}
</span>
</div>
<Link
to="/settings"
aria-label="Settings"
className="grid size-9 place-items-center rounded-lg border border-space-600 bg-space-900/80 text-ink-300 transition hover:border-amber-400/60 hover:text-amber-200"
>
<Icon name="gear" className="size-5" />
</Link>
</div>
</header>
{banner}

View File

@ -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 (
<Screen className="max-w-2xl">
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
<Section title="Company">
<Row label="Company name">{state.company.name}</Row>
</Section>
<Section title="Display">
<Choice<ClockFormat>
label="Clock"
value={prefs.clock}
options={[
["locale", "Device"],
["12h", "12-hour"],
["24h", "24-hour"],
]}
onChange={(clock) => prefs.set({ clock })}
/>
<Choice<CountdownFormat>
label="Countdowns"
value={prefs.countdown}
options={[
["real", "Real time"],
["game", "Game time"],
["both", "Both"],
]}
onChange={(countdown) => prefs.set({ countdown })}
/>
<Choice<"device" | "on" | "off">
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" })}
/>
</Section>
<Section title="Help">
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
useCamera.setState(DEFAULT_CAMERA);
prefs.set({ mapCamera: null });
}}
>
Reset map view
</Button>
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
Show tips again
</Button>
</div>
</Section>
<Section title="About">
<Row label="Game">Deadhead: Space Freight Tycoon</Row>
</Section>
{isDevBuild() && (
<Section title="Developer">
<p className="text-sm text-ink-400">Development build tools.</p>
</Section>
)}
</Screen>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
const id = `settings-${title.toLowerCase()}`;
return (
<Panel as="section" aria-labelledby={id} className="space-y-3">
<h2 id={id} className="font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
{title}
</h2>
{children}
</Panel>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-ink-400">{label}</span>
<span className="text-right text-ink-100">{children}</span>
</div>
);
}
/** A labelled radio group of a few options, styled as a segmented control. */
function Choice<T extends string>(props: {
label: string;
value: T;
options: [T, string][];
onChange: (value: T) => void;
}) {
return (
<fieldset className="flex flex-wrap items-center justify-between gap-2 text-sm">
<legend className="float-left text-ink-400">{props.label}</legend>
<div className="flex overflow-hidden rounded-lg border border-space-600">
{props.options.map(([value, text]) => (
<label
key={value}
className="cursor-pointer px-3 py-1.5 has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
>
<input
type="radio"
name={props.label}
value={value}
checked={props.value === value}
onChange={() => props.onChange(value)}
className="sr-only"
/>
{text}
</label>
))}
</div>
</fieldset>
);
}

View File

@ -82,6 +82,12 @@ const PATHS = {
</>
),
close: <path d="M6 6l12 12M18 6 6 18" />,
gear: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1" />
</>
),
plus: <path d="M12 5v14M5 12h14" />,
target: (
<>

View File

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

View File

@ -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