Step 10K.3: clock, countdown and reduced-motion settings applied everywhere

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 23:11:34 -04:00
parent 74cc29c8e3
commit 754242dde6
20 changed files with 306 additions and 48 deletions

View File

@ -1051,3 +1051,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 10K.3
- **The default "device" clock changes how the game clock looks** on 12-hour devices ("2145-07-11 7:18 PM"); before, it was always 24-hour. That is decision 0.5.76's default; players who prefer 24-hour pick it in Settings.
- **Countdowns default to real time everywhere, Priority windows included.** CR-007's chip showed game hours and switched to real time on tap; it now shows the chosen countdown format, with both in its tooltip, so one setting governs every span.
- **Game-time spans read "4.3 game h"** so they can't be mistaken for real hours next to a real countdown.
- **Reduced motion has two sources**: the OS media query (unless the player turned motion back on) and the player's override. The map keeps moving ships but stills its background.
- **Pure helpers read preferences directly** (`displayPreferences()`) where they format strings outside React (the dispatch sheet's problem lines); those re-render every second with the clock anyway.

View File

@ -15,6 +15,7 @@ import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots";
import { Settings } from "./screens/Settings";
import { useMotionPreference } from "./state/preferences";
import { StartScreen } from "./screens/StartScreen";
import { usePlayerState, useRefetchOnArrival } from "./state/player";
import { Button } from "./ui";
@ -50,6 +51,7 @@ function useServerClock() {
/** The whole client. Router, API and query providers come from the caller. */
export function App() {
useServerClock();
useMotionPreference();
const state = usePlayerState();
useRefetchOnArrival(state.data ?? null, state.refetch);
const retry = () => void state.refetch();

View File

@ -1,14 +1,14 @@
import { content } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { usePreferences } from "../state/preferences";
import { useClock } from "./clock";
import { formatGameTime } from "./timeFormat";
/** "2100-03-14 08:00": the in-game date and hour, in UTC. */
export function formatGameTime(gameTime: number): string {
return new Date(gameTime).toISOString().slice(0, 16).replace("T", " ");
}
export { formatGameTime };
/** The ticking in-game clock shown in the header. */
/** The ticking in-game clock shown in the header, in the player's clock format. */
export function GameClock() {
const now = useClock((state) => state.now);
return <time>{formatGameTime(realToGame(now, content.config))}</time>;
const clock = usePreferences((p) => p.clock);
return <time>{formatGameTime(realToGame(now, content.config), clock)}</time>;
}

View File

@ -0,0 +1,68 @@
import { content } from "@deadhead/content";
import { MS_PER_HOUR } from "@deadhead/sim";
import { formatDuration } from "../format";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
/**
* The one place times and spans are formatted for display (CR-011): clock
* times follow the clock preference, spans follow the countdown preference.
*/
function hour12For(clock: ClockFormat): boolean | undefined {
return clock === "locale" ? undefined : clock === "12h";
}
/** A real time of day on this device, e.g. "14:05" or "2:05 PM". */
export function formatTimeOfDay(time: number, clock: ClockFormat): string {
return new Date(time).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: hour12For(clock),
});
}
/** The in-game date and time (UTC), e.g. "2100-03-14 08:00" or "2100-03-14 8:00 AM". */
export function formatGameTime(gameTime: number, clock: ClockFormat = "24h"): string {
const date = new Date(gameTime).toISOString().slice(0, 10);
if (clock === "24h") return `${date} ${new Date(gameTime).toISOString().slice(11, 16)}`;
const time = new Date(gameTime).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
hour12: hour12For(clock),
timeZone: "UTC",
});
return `${date} ${time}`;
}
/** A span of game hours, e.g. "4.3 game h" or "62 game h". */
export function formatGameSpan(gameHours: number): string {
const rounded = gameHours < 10 ? gameHours.toFixed(1) : String(Math.round(gameHours));
return `${rounded} game h`;
}
/**
* A span of real milliseconds as the player wants countdowns: real time
* ("4m 16s"), game time ("4.3 game h") or both ("4m 16s · 4.3 game h").
* `real` formats the real part (e.g. mm:ss for the board refresh).
*/
export function formatSpan(
ms: number,
mode: CountdownFormat,
real: (ms: number) => string = formatDuration,
): string {
const game = formatGameSpan((Math.max(0, ms) * content.config.timeCompression) / MS_PER_HOUR);
if (mode === "game") return game;
if (mode === "both") return `${real(ms)} · ${game}`;
return real(ms);
}
/** A span given in game hours (e.g. a Priority window), in the countdown mode. */
export function formatGameHoursSpan(gameHours: number, mode: CountdownFormat): string {
return formatSpan((gameHours * MS_PER_HOUR) / content.config.timeCompression, mode);
}
/** The current preferences, for formatting outside React components. */
export function displayPreferences(): { clock: ClockFormat; countdown: CountdownFormat } {
const { clock, countdown } = usePreferences.getState();
return { clock, countdown };
}

View File

@ -1,7 +1,13 @@
import { useClock } from "../clock/clock";
import { formatSpan } from "../clock/timeFormat";
import { formatDuration } from "../format";
import { usePreferences } from "../state/preferences";
/** Live time remaining until `to` (server clock); shows `done` once it has passed. */
/**
* Live time remaining until `to` (server clock), in the player's countdown
* format (real, game or both; CR-011); shows `done` once it has passed.
* `format` styles the real-time part (e.g. mm:ss).
*/
export function Countdown({
to,
done = "Arriving…",
@ -12,6 +18,11 @@ export function Countdown({
format?: (ms: number) => string;
}) {
const now = useClock((s) => s.now);
const mode = usePreferences((p) => p.countdown);
const remaining = to - now;
return <span className="tabular-nums">{remaining > 0 ? format(remaining) : done}</span>;
return (
<span className="tabular-nums">
{remaining > 0 ? formatSpan(remaining, mode, format) : done}
</span>
);
}

View File

@ -1,8 +1,9 @@
import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema";
import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim";
import { formatGameHoursSpan } from "../clock/timeFormat";
import { usePreferences } from "../state/preferences";
import { shipStatsOf } from "../game";
import { useState } from "react";
import { cargoName, formatDuration } from "../format";
import { Pill, type Tone } from "../ui";
@ -41,25 +42,20 @@ export function ClassChip({ contract }: { contract: Pick<Contract, "cargoTypeId"
}
/**
* "PRIORITY · within 5h" (game hours). Tap to switch to real time; hovering
* shows both.
* "PRIORITY · within 5m 00s": the delivery window, in the player's countdown
* format (CR-011); hovering shows game and real time.
*/
export function PriorityChip({ windowHours }: { windowHours: number }) {
const [real, setReal] = useState(false);
const mode = usePreferences((p) => p.countdown);
return (
<button
type="button"
onClick={() => setReal(!real)}
<span
title={`Within ${formatWindow(windowHours)} game time, about ${realTimeOf(windowHours)} real time`}
className="rounded-full"
>
<Pill tone="amber" icon="bolt">
Priority · within{" "}
<span className="normal-case">
{real ? realTimeOf(windowHours) : formatWindow(windowHours)}
</span>
<span className="normal-case">{formatGameHoursSpan(windowHours, mode)}</span>
</Pill>
</button>
</span>
);
}

View File

@ -95,13 +95,24 @@
}
}
/* Motion is decoration only: switch it off when asked. */
/*
* Motion is decoration only: switch it off when asked. The app sets
* data-reduced-motion on <html> from the player's setting (CR-011); without
* an override it follows the device.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
:root:not([data-reduced-motion="false"]) *,
:root:not([data-reduced-motion="false"]) *::before,
:root:not([data-reduced-motion="false"]) *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
:root[data-reduced-motion="true"] *,
:root[data-reduced-motion="true"] *::before,
:root[data-reduced-motion="true"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}

View File

@ -310,3 +310,11 @@ export function decorate(
}
return result;
}
/**
* The map style with motion settings applied (CR-011): reduced motion keeps the
* background still (no parallax, no zoom follow). Ships still move.
*/
export function mapMotionStyle(reducedMotion: boolean, style: MapStyle = mapStyle): MapStyle {
return reducedMotion ? { ...style, parallax: 0, backgroundZoomFollow: 0 } : style;
}

View File

@ -2,7 +2,8 @@ import { ColorMatrixFilter, Container, Graphics, Sprite, Text, type Texture } fr
import type { ArtTextures } from "./art/load";
import { sceneVisuals, type Visual } from "./art/visuals";
import { toScreen, type Camera, type Viewport } from "./camera";
import { backgroundRect, decorate, layoutScene, polar } from "./layout";
import { reducedMotionOn, usePreferences } from "../state/preferences";
import { backgroundRect, decorate, layoutScene, mapMotionStyle, polar } from "./layout";
import type { MapSelection } from "./hitTest";
import { locationOverlays } from "./overlays";
import type { MapPoint } from "./projection";
@ -136,7 +137,8 @@ export class MapRenderer {
for (const sprite of this.sprites.values()) sprite.visible = false;
if (this.background) {
const r = backgroundRect(view, camera, this.background.texture);
const style = mapMotionStyle(reducedMotionOn(usePreferences.getState()));
const r = backgroundRect(view, camera, this.background.texture, style);
this.background.position.set(r.x, r.y);
this.background.setSize(r.width, r.height);
}

View File

@ -31,7 +31,14 @@ import { location, tripRealMs, shipStatsOf, simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Sheet } from "../ui";
import { formatWindow, goodName, PriorityChip } from "../components/Goods";
import { goodName, PriorityChip } from "../components/Goods";
import {
displayPreferences,
formatGameHoursSpan,
formatSpan,
formatTimeOfDay,
} from "../clock/timeFormat";
import { usePreferences } from "../state/preferences";
/** Short player-facing reasons a ship+pilot cannot take a contract. */
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
@ -68,7 +75,9 @@ export function explainProblems(
const problems: string[] = [];
// Resting blocks flying (CR-004): first, with the time left.
if (pilot.resting && pilot.restEndsAt !== null) {
problems.push(`Resting — ${formatClock(pilot.restEndsAt - now)}`);
problems.push(
`Resting — ${formatSpan(pilot.restEndsAt - now, displayPreferences().countdown, formatClock)}`,
);
}
if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]);
if (contract.massTonnes > model.cargoCapacityTonnes) {
@ -92,7 +101,9 @@ export function explainProblems(
if (window !== null) {
const trip = contractTripGameHours(model, contract, now, content);
if (trip > window) {
problems.push(`Too slow — ${formatWindow(trip)} vs ${formatWindow(window)} window`);
problems.push(
`Too slow — ${formatGameHoursSpan(trip, displayPreferences().countdown)} vs ${formatGameHoursSpan(window, displayPreferences().countdown)} window`,
);
}
}
if (contract.requiredSkill) {
@ -116,7 +127,7 @@ export function explainProblems(
/** Real-world arrival time on the device, e.g. "14:05". */
export function formatLocalTime(time: number): string {
return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return formatTimeOfDay(time, displayPreferences().clock);
}
export interface PairOption {
@ -201,7 +212,9 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now);
// In the shop (CR-009): first, with the time left.
if (isInShop(ship, now))
all.unshift(`In the shop — ${formatClock(ship.inShopUntil! - now)}`);
all.unshift(
`In the shop — ${formatSpan(ship.inShopUntil! - now, displayPreferences().countdown, formatClock)}`,
);
problems = all.length > 0 ? all : [INELIGIBLE_REASONS[check.reason]];
}
}
@ -233,6 +246,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
/** Choose a ship and pilot for a contract, see the numbers, and confirm (Step 9.5). */
export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) {
const state = useLoadedPlayer();
const prefsCountdown = usePreferences((p) => p.countdown);
const now = useClock((s) => s.now);
const dispatch = useGameAction("createDispatch");
const options = pairOptions(state, contract, now);
@ -339,7 +353,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
<Cell
label="ETA (game)"
value={formatGameTime(realToGame(option.arrivalAt, content.config))}
value={formatGameTime(
realToGame(option.arrivalAt, content.config),
displayPreferences().clock,
)}
/>
<Cell label="ETA (real)" value={formatLocalTime(option.arrivalAt)} />
{option.duty && (
@ -352,9 +369,14 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
)}
{!blocked && option.priority && (
<p aria-label="Priority margin" className="mt-2 text-amber-200">
Priority: {formatWindow(option.priority.tripHours)} trip,{" "}
{formatWindow(option.priority.windowHours - option.priority.tripHours)} to
spare in the {formatWindow(option.priority.windowHours)} window
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
trip,{" "}
{formatGameHoursSpan(
option.priority.windowHours - option.priority.tripHours,
prefsCountdown,
)}{" "}
to spare in the{" "}
{formatGameHoursSpan(option.priority.windowHours, prefsCountdown)} window
</p>
)}
{!blocked && option.duty?.willRest && (

View File

@ -10,6 +10,7 @@ import { LoanCard } from "../components/LoanCard";
import { RouteStrip } from "../components/RouteStrip";
import { formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { buttonClass, Eyebrow, Icon, Panel, Screen, ScreenHeader, StatTile } from "../ui";
/** What the player needs to do next to reach the deal (decision 0.5.30). */
@ -27,6 +28,7 @@ export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): s
export function HQ() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const clockFormat = usePreferences((p) => p.clock);
const idle = state.ships.filter((s) => s.status === "idle").length;
const flying = state.ships.filter((s) => s.status === "in-flight").length;
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
@ -56,7 +58,7 @@ export function HQ() {
/>
<StatTile
label="Game date"
value={formatGameTime(realToGame(now, content.config))}
value={formatGameTime(realToGame(now, content.config), clockFormat)}
icon="clock"
/>
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />

View File

@ -12,6 +12,7 @@ import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { Art, buttonClass, Icon } from "../ui";
const TYPE_LABELS: Record<Location["type"], string> = {
@ -157,6 +158,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
/** A tapped ship in flight: its route, pilot and ETA. */
function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) {
const state = useLoadedPlayer();
const clockFormat = usePreferences((p) => p.clock);
const dispatch = state.activeDispatches.find((d) => d.id === dispatchId);
if (!dispatch) return null;
const shipName = dispatch.tutorial
@ -183,7 +185,7 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: ()
<span className="font-display text-cyan-200">
<Countdown to={dispatch.arrivalAt} />
</span>{" "}
({formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
({formatGameTime(realToGame(dispatch.arrivalAt, content.config), clockFormat)})
</p>
</Panel>
);

View File

@ -1,3 +1,4 @@
import { useEffect } from "react";
import { create, type StoreApi, type UseBoundStore } from "zustand";
import type { Camera } from "../map/camera";
@ -132,3 +133,16 @@ export const usePreferences = createPreferencesStore();
export function reducedMotionOn(prefs: Pick<Preferences, "reducedMotion">): boolean {
return prefs.reducedMotion ?? deviceReducesMotion();
}
/**
* Mirrors the reduced-motion override onto `<html data-reduced-motion>`, which
* the stylesheet uses; no attribute means "follow the device".
*/
export function useMotionPreference(): void {
const override = usePreferences((p) => p.reducedMotion);
useEffect(() => {
const root = document.documentElement;
if (override === null) delete root.dataset.reducedMotion;
else root.dataset.reducedMotion = String(override);
}, [override]);
}

View File

@ -52,7 +52,8 @@ describe("server-synced clock", () => {
await syncClock(serverAhead(0));
const stop = startClockTicker();
render(<GameClock />);
const at = (real: number) => formatGameTime(realToGame(real, content.config));
// The default clock follows the device locale (CR-011).
const at = (real: number) => formatGameTime(realToGame(real, content.config), "locale");
expect(screen.getByText(at(DEVICE_NOW))).toBeInTheDocument();
// One real minute is one game hour.

View File

@ -1,6 +1,5 @@
import type { Contract, Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { realTimeOf } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
@ -78,9 +77,8 @@ describe("contract card (CR-007)", () => {
const c = await card("3,210 cr");
// A Priority good can have its own name.
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
const chip = within(c).getByRole("button", { name: /Priority · within 5h/ });
await userEvent.click(chip);
expect(chip).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
// The window follows the countdown preference: real time by default (CR-011).
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
expect(c).not.toHaveTextContent("Too slow");
});

View File

@ -4,8 +4,8 @@ import { contractTripGameHours, priorityWindow } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatGameHoursSpan } from "../src/clock/timeFormat";
import { useClock } from "../src/clock/clock";
import { formatWindow } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -102,7 +102,7 @@ describe("Priority in the dispatch sheet (CR-007)", () => {
const fast = option(await openSheet(), "Quick Finch");
expect(within(fast).getByRole("radio")).toBeEnabled();
expect(within(fast).getByLabelText("Priority margin")).toHaveTextContent(
`Priority: ${formatWindow(trip("courier"))} trip, ${formatWindow(window - trip("courier"))} to spare in the ${formatWindow(window)} window`,
`Priority: ${formatGameHoursSpan(trip("courier"), "real")} trip, ${formatGameHoursSpan(window - trip("courier"), "real")} to spare in the ${formatGameHoursSpan(window, "real")} window`,
);
});
@ -112,7 +112,7 @@ describe("Priority in the dispatch sheet (CR-007)", () => {
const slow = option(await openSheet(), "Old Barge");
expect(within(slow).getByRole("radio")).toBeDisabled();
expect(within(slow).getByRole("list", { name: "Problems" })).toHaveTextContent(
`Too slow — ${formatWindow(trip("bulk-hauler"))} vs ${formatWindow(window)} window`,
`Too slow — ${formatGameHoursSpan(trip("bulk-hauler"), "real")} vs ${formatGameHoursSpan(window, "real")} window`,
);
});
});

View File

@ -134,7 +134,7 @@ describe("dispatch sheet", () => {
expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`);
expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`);
expect(freighter).toHaveTextContent(
`ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config))}`,
`ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config), "locale")}`,
);
expect(freighter).toHaveTextContent(`ETA (real)${formatLocalTime(NOW + tripMs)}`);
});

View File

@ -0,0 +1,112 @@
import { content } from "@deadhead/content";
import type { Contract } from "@deadhead/schema";
import { MS_PER_HOUR, realToGame } from "@deadhead/sim";
import { act, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import {
formatGameHoursSpan,
formatGameSpan,
formatGameTime,
formatSpan,
formatTimeOfDay,
} from "../src/clock/timeFormat";
import { formatClock } from "../src/format";
import { mapMotionStyle } from "../src/map/layout";
import { mapStyle } from "../src/map/style";
import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
/** 4m 16s of real time is 4.27 game hours at 60× compression. */
const SPAN = 4 * 60_000 + 16_000;
describe("time and span helpers (Step 10K.3)", () => {
it("formats game time in 24-hour and 12-hour clocks", () => {
const t = Date.parse("2100-03-14T13:05:00Z");
expect(formatGameTime(t, "24h")).toBe("2100-03-14 13:05");
expect(formatGameTime(t, "12h")).toMatch(/^2100-03-14 1:05\sPM$/);
});
it("formats a real time of day in each clock mode", () => {
const t = new Date(2100, 0, 1, 15, 7).getTime();
expect(formatTimeOfDay(t, "24h")).toBe("15:07");
expect(formatTimeOfDay(t, "12h")).toMatch(/^0?3:07\sPM$/);
expect(formatTimeOfDay(t, "locale")).toBe(
new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
);
});
it("formats spans as real time, game time or both", () => {
expect(formatSpan(SPAN, "real")).toBe("4m 16s");
expect(formatSpan(SPAN, "game")).toBe("4.3 game h");
expect(formatSpan(SPAN, "both")).toBe("4m 16s · 4.3 game h");
expect(formatSpan(SPAN, "both", formatClock)).toBe("04:16 · 4.3 game h");
expect(formatGameSpan(62.4)).toBe("62 game h");
expect(formatGameHoursSpan(5, "game")).toBe("5.0 game h");
expect(formatGameHoursSpan(5, "real")).toBe(
formatSpan((5 * MS_PER_HOUR) / content.config.timeCompression, "real"),
);
});
it("stills the map background with reduced motion", () => {
expect(mapMotionStyle(true).parallax).toBe(0);
expect(mapMotionStyle(true).backgroundZoomFollow).toBe(0);
expect(mapMotionStyle(false).parallax).toBe(mapStyle.parallax);
});
});
describe("display options in the app (Step 10K.3)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => {
vi.useRealTimers();
act(() => usePreferences.getState().set(DEFAULT_PREFERENCES));
});
it("switches the HQ clock to 24-hour", async () => {
act(() => usePreferences.getState().set({ clock: "12h" }));
renderApp(fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }), "/");
const game = realToGame(NOW, content.config);
expect(await screen.findByLabelText("Game clock")).toHaveTextContent(
formatGameTime(game, "12h"),
);
act(() => usePreferences.getState().set({ clock: "24h" }));
expect(screen.getByLabelText("Game clock")).toHaveTextContent(formatGameTime(game, "24h"));
});
it("shows contract expiry in both real and game time when asked", async () => {
const job: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 1234,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + SPAN,
firstContact: false,
};
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState({ tutorial: null }),
getContractBoard: () => ({ contracts: [job], periodEndsAt: NOW + 600_000 }),
}),
"/contracts?location=earth",
);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
expect(card).toHaveTextContent("expires in 4m 16s");
act(() => usePreferences.getState().set({ countdown: "both" }));
expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h");
});
});

View File

@ -16,7 +16,8 @@ describe("app layout", () => {
expect(await screen.findByRole("banner")).toBeInTheDocument();
expect(screen.getByLabelText("Credits")).toHaveTextContent("1,234 cr");
expect(screen.getByLabelText("Game clock")).toHaveTextContent(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/,
// Device-locale clock by default (CR-011): 24-hour or 12-hour.
/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}( [AP]M)?$/,
);
const nav = screen.getByRole("navigation", { name: "Main" });
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);

View File

@ -254,7 +254,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.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
- [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
- [ ] Step 10K.5 — Server: rename company
- [ ] Step 10K.6 — Server: start a new company