Step 10K.2: settings screen behind a header gear
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c73c9a4116
commit
74cc29c8e3
|
|
@ -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.
|
- **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.
|
- **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`.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { HQ } from "./screens/HQ";
|
||||||
import { Locations } from "./screens/Locations";
|
import { Locations } from "./screens/Locations";
|
||||||
import { Market } from "./screens/Market";
|
import { Market } from "./screens/Market";
|
||||||
import { Pilots } from "./screens/Pilots";
|
import { Pilots } from "./screens/Pilots";
|
||||||
|
import { Settings } from "./screens/Settings";
|
||||||
import { StartScreen } from "./screens/StartScreen";
|
import { StartScreen } from "./screens/StartScreen";
|
||||||
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||||
import { Button } from "./ui";
|
import { Button } from "./ui";
|
||||||
|
|
@ -106,6 +107,8 @@ export function App() {
|
||||||
element={SCREEN_COMPONENTS[screen.path]}
|
element={SCREEN_COMPONENTS[screen.path]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Settings opens from the header gear, not a tab (CR-011). */}
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
<AwaySummary />
|
<AwaySummary />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { NavLink, Outlet } from "react-router";
|
import { Link, NavLink, Outlet } from "react-router";
|
||||||
import { GameClock } from "../clock/GameClock";
|
import { GameClock } from "../clock/GameClock";
|
||||||
import { SCREENS, type ScreenPath } from "../routes";
|
import { SCREENS, type ScreenPath } from "../routes";
|
||||||
import { Icon } from "../ui";
|
import { Icon } from "../ui";
|
||||||
|
|
@ -50,6 +50,13 @@ export function Layout({
|
||||||
{clock}
|
{clock}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{banner}
|
{banner}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,12 @@ const PATHS = {
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
close: <path d="M6 6l12 12M18 6 6 18" />,
|
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" />,
|
plus: <path d="M12 5v14M5 12h14" />,
|
||||||
target: (
|
target: (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
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).
|
- [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.3 — Client: display options applied everywhere
|
||||||
- [ ] Step 10K.4 — Client: reset map view and show tips again
|
- [ ] Step 10K.4 — Client: reset map view and show tips again
|
||||||
- [ ] Step 10K.5 — Server: rename company
|
- [ ] Step 10K.5 — Server: rename company
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue