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:
parent
74cc29c8e3
commit
754242dde6
|
|
@ -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.
|
- **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).
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ 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 { Settings } from "./screens/Settings";
|
||||||
|
import { useMotionPreference } from "./state/preferences";
|
||||||
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";
|
||||||
|
|
@ -50,6 +51,7 @@ function useServerClock() {
|
||||||
/** The whole client. Router, API and query providers come from the caller. */
|
/** The whole client. Router, API and query providers come from the caller. */
|
||||||
export function App() {
|
export function App() {
|
||||||
useServerClock();
|
useServerClock();
|
||||||
|
useMotionPreference();
|
||||||
const state = usePlayerState();
|
const state = usePlayerState();
|
||||||
useRefetchOnArrival(state.data ?? null, state.refetch);
|
useRefetchOnArrival(state.data ?? null, state.refetch);
|
||||||
const retry = () => void state.refetch();
|
const retry = () => void state.refetch();
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { realToGame } from "@deadhead/sim";
|
import { realToGame } from "@deadhead/sim";
|
||||||
|
import { usePreferences } from "../state/preferences";
|
||||||
import { useClock } from "./clock";
|
import { useClock } from "./clock";
|
||||||
|
import { formatGameTime } from "./timeFormat";
|
||||||
|
|
||||||
/** "2100-03-14 08:00": the in-game date and hour, in UTC. */
|
export { formatGameTime };
|
||||||
export function formatGameTime(gameTime: number): string {
|
|
||||||
return new Date(gameTime).toISOString().slice(0, 16).replace("T", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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() {
|
export function GameClock() {
|
||||||
const now = useClock((state) => state.now);
|
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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
|
import { formatSpan } from "../clock/timeFormat";
|
||||||
import { formatDuration } from "../format";
|
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({
|
export function Countdown({
|
||||||
to,
|
to,
|
||||||
done = "Arriving…",
|
done = "Arriving…",
|
||||||
|
|
@ -12,6 +18,11 @@ export function Countdown({
|
||||||
format?: (ms: number) => string;
|
format?: (ms: number) => string;
|
||||||
}) {
|
}) {
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
|
const mode = usePreferences((p) => p.countdown);
|
||||||
const remaining = to - now;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract, PlayerState } from "@deadhead/schema";
|
import type { Contract, PlayerState } from "@deadhead/schema";
|
||||||
import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim";
|
import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim";
|
||||||
|
import { formatGameHoursSpan } from "../clock/timeFormat";
|
||||||
|
import { usePreferences } from "../state/preferences";
|
||||||
import { shipStatsOf } from "../game";
|
import { shipStatsOf } from "../game";
|
||||||
import { useState } from "react";
|
|
||||||
import { cargoName, formatDuration } from "../format";
|
import { cargoName, formatDuration } from "../format";
|
||||||
import { Pill, type Tone } from "../ui";
|
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
|
* "PRIORITY · within 5m 00s": the delivery window, in the player's countdown
|
||||||
* shows both.
|
* format (CR-011); hovering shows game and real time.
|
||||||
*/
|
*/
|
||||||
export function PriorityChip({ windowHours }: { windowHours: number }) {
|
export function PriorityChip({ windowHours }: { windowHours: number }) {
|
||||||
const [real, setReal] = useState(false);
|
const mode = usePreferences((p) => p.countdown);
|
||||||
return (
|
return (
|
||||||
<button
|
<span
|
||||||
type="button"
|
|
||||||
onClick={() => setReal(!real)}
|
|
||||||
title={`Within ${formatWindow(windowHours)} game time, about ${realTimeOf(windowHours)} real time`}
|
title={`Within ${formatWindow(windowHours)} game time, about ${realTimeOf(windowHours)} real time`}
|
||||||
className="rounded-full"
|
|
||||||
>
|
>
|
||||||
<Pill tone="amber" icon="bolt">
|
<Pill tone="amber" icon="bolt">
|
||||||
Priority · within{" "}
|
Priority · within{" "}
|
||||||
<span className="normal-case">
|
<span className="normal-case">{formatGameHoursSpan(windowHours, mode)}</span>
|
||||||
{real ? realTimeOf(windowHours) : formatWindow(windowHours)}
|
|
||||||
</span>
|
|
||||||
</Pill>
|
</Pill>
|
||||||
</button>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
:root:not([data-reduced-motion="false"]) *,
|
||||||
*::before,
|
:root:not([data-reduced-motion="false"]) *::before,
|
||||||
*::after {
|
:root:not([data-reduced-motion="false"]) *::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -310,3 +310,11 @@ export function decorate(
|
||||||
}
|
}
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { ColorMatrixFilter, Container, Graphics, Sprite, Text, type Texture } fr
|
||||||
import type { ArtTextures } from "./art/load";
|
import type { ArtTextures } from "./art/load";
|
||||||
import { sceneVisuals, type Visual } from "./art/visuals";
|
import { sceneVisuals, type Visual } from "./art/visuals";
|
||||||
import { toScreen, type Camera, type Viewport } from "./camera";
|
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 type { MapSelection } from "./hitTest";
|
||||||
import { locationOverlays } from "./overlays";
|
import { locationOverlays } from "./overlays";
|
||||||
import type { MapPoint } from "./projection";
|
import type { MapPoint } from "./projection";
|
||||||
|
|
@ -136,7 +137,8 @@ export class MapRenderer {
|
||||||
for (const sprite of this.sprites.values()) sprite.visible = false;
|
for (const sprite of this.sprites.values()) sprite.visible = false;
|
||||||
|
|
||||||
if (this.background) {
|
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.position.set(r.x, r.y);
|
||||||
this.background.setSize(r.width, r.height);
|
this.background.setSize(r.width, r.height);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,14 @@ import { location, tripRealMs, shipStatsOf, simShip } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { RouteStrip } from "../components/RouteStrip";
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
import { Art, Button, Sheet } from "../ui";
|
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. */
|
/** Short player-facing reasons a ship+pilot cannot take a contract. */
|
||||||
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||||
|
|
@ -68,7 +75,9 @@ export function explainProblems(
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
// Resting blocks flying (CR-004): first, with the time left.
|
// Resting blocks flying (CR-004): first, with the time left.
|
||||||
if (pilot.resting && pilot.restEndsAt !== null) {
|
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 (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]);
|
||||||
if (contract.massTonnes > model.cargoCapacityTonnes) {
|
if (contract.massTonnes > model.cargoCapacityTonnes) {
|
||||||
|
|
@ -92,7 +101,9 @@ export function explainProblems(
|
||||||
if (window !== null) {
|
if (window !== null) {
|
||||||
const trip = contractTripGameHours(model, contract, now, content);
|
const trip = contractTripGameHours(model, contract, now, content);
|
||||||
if (trip > window) {
|
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) {
|
if (contract.requiredSkill) {
|
||||||
|
|
@ -116,7 +127,7 @@ export function explainProblems(
|
||||||
|
|
||||||
/** Real-world arrival time on the device, e.g. "14:05". */
|
/** Real-world arrival time on the device, e.g. "14:05". */
|
||||||
export function formatLocalTime(time: number): string {
|
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 {
|
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);
|
const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now);
|
||||||
// In the shop (CR-009): first, with the time left.
|
// In the shop (CR-009): first, with the time left.
|
||||||
if (isInShop(ship, now))
|
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]];
|
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). */
|
/** 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 }) {
|
export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
|
const prefsCountdown = usePreferences((p) => p.countdown);
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
const dispatch = useGameAction("createDispatch");
|
const dispatch = useGameAction("createDispatch");
|
||||||
const options = pairOptions(state, contract, now);
|
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="Net profit" value={formatCredits(option.netProfit)} good />
|
||||||
<Cell
|
<Cell
|
||||||
label="ETA (game)"
|
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)} />
|
<Cell label="ETA (real)" value={formatLocalTime(option.arrivalAt)} />
|
||||||
{option.duty && (
|
{option.duty && (
|
||||||
|
|
@ -352,9 +369,14 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
)}
|
)}
|
||||||
{!blocked && option.priority && (
|
{!blocked && option.priority && (
|
||||||
<p aria-label="Priority margin" className="mt-2 text-amber-200">
|
<p aria-label="Priority margin" className="mt-2 text-amber-200">
|
||||||
Priority: {formatWindow(option.priority.tripHours)} trip,{" "}
|
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
|
||||||
{formatWindow(option.priority.windowHours - option.priority.tripHours)} to
|
trip,{" "}
|
||||||
spare in the {formatWindow(option.priority.windowHours)} window
|
{formatGameHoursSpan(
|
||||||
|
option.priority.windowHours - option.priority.tripHours,
|
||||||
|
prefsCountdown,
|
||||||
|
)}{" "}
|
||||||
|
to spare in the{" "}
|
||||||
|
{formatGameHoursSpan(option.priority.windowHours, prefsCountdown)} window
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!blocked && option.duty?.willRest && (
|
{!blocked && option.duty?.willRest && (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { LoanCard } from "../components/LoanCard";
|
||||||
import { RouteStrip } from "../components/RouteStrip";
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
import { formatCredits, locationName } from "../format";
|
import { formatCredits, locationName } from "../format";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
import { usePreferences } from "../state/preferences";
|
||||||
import { buttonClass, Eyebrow, Icon, Panel, Screen, ScreenHeader, StatTile } from "../ui";
|
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). */
|
/** 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() {
|
export function HQ() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
|
const clockFormat = usePreferences((p) => p.clock);
|
||||||
const idle = state.ships.filter((s) => s.status === "idle").length;
|
const idle = state.ships.filter((s) => s.status === "idle").length;
|
||||||
const flying = state.ships.filter((s) => s.status === "in-flight").length;
|
const flying = state.ships.filter((s) => s.status === "in-flight").length;
|
||||||
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
|
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
|
||||||
|
|
@ -56,7 +58,7 @@ export function HQ() {
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Game date"
|
label="Game date"
|
||||||
value={formatGameTime(realToGame(now, content.config))}
|
value={formatGameTime(realToGame(now, content.config), clockFormat)}
|
||||||
icon="clock"
|
icon="clock"
|
||||||
/>
|
/>
|
||||||
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
|
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ 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";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
import { usePreferences } from "../state/preferences";
|
||||||
import { Art, buttonClass, Icon } from "../ui";
|
import { Art, buttonClass, Icon } from "../ui";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<Location["type"], string> = {
|
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. */
|
/** A tapped ship in flight: its route, pilot and ETA. */
|
||||||
function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) {
|
function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
|
const clockFormat = usePreferences((p) => p.clock);
|
||||||
const dispatch = state.activeDispatches.find((d) => d.id === dispatchId);
|
const dispatch = state.activeDispatches.find((d) => d.id === dispatchId);
|
||||||
if (!dispatch) return null;
|
if (!dispatch) return null;
|
||||||
const shipName = dispatch.tutorial
|
const shipName = dispatch.tutorial
|
||||||
|
|
@ -183,7 +185,7 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: ()
|
||||||
<span className="font-display text-cyan-200">
|
<span className="font-display text-cyan-200">
|
||||||
<Countdown to={dispatch.arrivalAt} />
|
<Countdown to={dispatch.arrivalAt} />
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
({formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
({formatGameTime(realToGame(dispatch.arrivalAt, content.config), clockFormat)})
|
||||||
</p>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
import { create, type StoreApi, type UseBoundStore } from "zustand";
|
import { create, type StoreApi, type UseBoundStore } from "zustand";
|
||||||
import type { Camera } from "../map/camera";
|
import type { Camera } from "../map/camera";
|
||||||
|
|
||||||
|
|
@ -132,3 +133,16 @@ export const usePreferences = createPreferencesStore();
|
||||||
export function reducedMotionOn(prefs: Pick<Preferences, "reducedMotion">): boolean {
|
export function reducedMotionOn(prefs: Pick<Preferences, "reducedMotion">): boolean {
|
||||||
return prefs.reducedMotion ?? deviceReducesMotion();
|
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]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ describe("server-synced clock", () => {
|
||||||
await syncClock(serverAhead(0));
|
await syncClock(serverAhead(0));
|
||||||
const stop = startClockTicker();
|
const stop = startClockTicker();
|
||||||
render(<GameClock />);
|
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();
|
expect(screen.getByText(at(DEVICE_NOW))).toBeInTheDocument();
|
||||||
|
|
||||||
// One real minute is one game hour.
|
// One real minute is one game hour.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { Contract, Ship } from "@deadhead/schema";
|
import type { Contract, Ship } from "@deadhead/schema";
|
||||||
import { screen, within } from "@testing-library/react";
|
import { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { realTimeOf } from "../src/components/Goods";
|
import { realTimeOf } from "../src/components/Goods";
|
||||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||||
|
|
@ -78,9 +77,8 @@ describe("contract card (CR-007)", () => {
|
||||||
const c = await card("3,210 cr");
|
const c = await card("3,210 cr");
|
||||||
// A Priority good can have its own name.
|
// A Priority good can have its own name.
|
||||||
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
|
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
|
||||||
const chip = within(c).getByRole("button", { name: /Priority · within 5h/ });
|
// The window follows the countdown preference: real time by default (CR-011).
|
||||||
await userEvent.click(chip);
|
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
|
||||||
expect(chip).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
|
|
||||||
expect(c).not.toHaveTextContent("Too slow");
|
expect(c).not.toHaveTextContent("Too slow");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { contractTripGameHours, priorityWindow } from "@deadhead/sim";
|
||||||
import { screen, within } from "@testing-library/react";
|
import { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { formatGameHoursSpan } from "../src/clock/timeFormat";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
import { formatWindow } from "../src/components/Goods";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||||
|
|
||||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
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");
|
const fast = option(await openSheet(), "Quick Finch");
|
||||||
expect(within(fast).getByRole("radio")).toBeEnabled();
|
expect(within(fast).getByRole("radio")).toBeEnabled();
|
||||||
expect(within(fast).getByLabelText("Priority margin")).toHaveTextContent(
|
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");
|
const slow = option(await openSheet(), "Old Barge");
|
||||||
expect(within(slow).getByRole("radio")).toBeDisabled();
|
expect(within(slow).getByRole("radio")).toBeDisabled();
|
||||||
expect(within(slow).getByRole("list", { name: "Problems" })).toHaveTextContent(
|
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`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ describe("dispatch sheet", () => {
|
||||||
expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`);
|
expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`);
|
||||||
expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`);
|
expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`);
|
||||||
expect(freighter).toHaveTextContent(
|
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)}`);
|
expect(freighter).toHaveTextContent(`ETA (real)${formatLocalTime(NOW + tripMs)}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,7 +16,8 @@ describe("app layout", () => {
|
||||||
expect(await screen.findByRole("banner")).toBeInTheDocument();
|
expect(await screen.findByRole("banner")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Credits")).toHaveTextContent("1,234 cr");
|
expect(screen.getByLabelText("Credits")).toHaveTextContent("1,234 cr");
|
||||||
expect(screen.getByLabelText("Game clock")).toHaveTextContent(
|
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" });
|
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||||
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);
|
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);
|
||||||
|
|
|
||||||
|
|
@ -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.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`.
|
||||||
- [ ] 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.4 — Client: reset map view and show tips again
|
||||||
- [ ] 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue