diff --git a/src/client/App.tsx b/src/client/App.tsx index 5082ff2..d2f6dfc 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -31,6 +31,7 @@ import { import { clockFor, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; +import { metaOnTheme, useTheme } from "./state/theme.ts"; import { isCustomGameId, type CustomEvents, @@ -108,16 +109,26 @@ export function App() { const prog = useProgress(); const daily = useDailyLog(); const custom = useCustom(); + // Colour only: which ground the page is drawn on, written to the document by + // the hook. Nothing else in the app asks what it is — the tokens in + // styles.css answer for every component — except the hues below. + const theme = useTheme(prefs.theme); /** * How every lane in this tree is named and coloured. * * App owns it because App is the only thing holding the reader's own games, * and hands it down rather than letting components import a lookup that can * only ever answer for the tracked ones. + * + * It is also where a hue meets the theme. A hue is data — ours in `games.ts`, + * theirs in their browser — and all of it was picked against the dark ground, + * so on paper the bright ones need darkening to stay readable. Doing it here + * means every lane label, chip, rail and bar in the tree gets the adjusted + * answer without a single component knowing a theme exists. */ const gameMeta = useMemo( - () => (id) => metaFor(id, custom.games), - [custom.games], + () => (id) => metaOnTheme(metaFor(id, custom.games), theme), + [custom.games, theme], ); // "Completed" is now one status among several; the rest of the UI still asks // this question a lot, so keep a cheap shorthand. diff --git a/src/client/components/Colophon.tsx b/src/client/components/Colophon.tsx index b1b417e..59df791 100644 --- a/src/client/components/Colophon.tsx +++ b/src/client/components/Colophon.tsx @@ -1,4 +1,4 @@ -import { gameMeta } from "../../shared/games.ts"; +import { useGameMeta } from "../state/gameMeta.tsx"; import { freshness, type SourceHealth } from "../../shared/feed.ts"; import { formatAbsolute, formatRemaining } from "../../shared/time.ts"; @@ -128,7 +128,13 @@ export function Colophon({ sources: SourceHealth[]; now: number; }) { - const games = [...new Set(sources.map((s) => s.game))].map(gameMeta); + // The tree's resolver rather than the module lookup: it is the one that + // answers for a reader's own games, and the one that reads a hue for the + // theme the page is in. + const gameMeta = useGameMeta(); + const games = [...new Set(sources.map((s) => s.game))].map((id) => + gameMeta(id), + ); const studios = [...new Set(games.map((g) => g.studio))]; const { refreshedAt, stale } = freshness(sources, now); @@ -282,6 +288,26 @@ export function Colophon({ {"."}

+ {IDEA_CREDITS.length > 0 && ( +

+ Additional ideas and design from{" "} + {IDEA_CREDITS.map((c, i) => ( + + {i > 0 && (i === IDEA_CREDITS.length - 1 ? " and " : ", ")} + + {c.handle} + + + ))} + {"."} +

+ )} +

- {IDEA_CREDITS.length > 0 && ( -

- Additional ideas and design from{" "} - {IDEA_CREDITS.map((c, i) => ( - - {i > 0 && (i === IDEA_CREDITS.length - 1 ? " and " : ", ")} - - {c.handle} - - - ))} - {"."} -

- )} - {/* Placed under the disclaimer that admits dates can be wrong, because that paragraph is where a reader who has just found one is looking. The bug diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 0cd94e7..58eb8cd 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -5,6 +5,7 @@ import { guessRegion } from "../../shared/time.ts"; import type { SortMode } from "./sort.ts"; import { KEYS, readJson, writeJson } from "./storage.ts"; import type { TimelineGroup } from "./lanes.ts"; +import { DEFAULT_THEME_CHOICE, type ThemeChoice } from "./theme.ts"; import { DEFAULT_DAY_WIDTH } from "./zoom.ts"; /** @@ -98,6 +99,16 @@ export interface Prefs { showCompleted: boolean; /** Reveal events the reader has ignored, so they can be restored. */ showIgnored: boolean; + /** + * Which ground the app is drawn on: `dark`, `light`, or `system` to follow + * the device. + * + * Dark is the default and not a placeholder — see `DEFAULT_THEME_CHOICE`. The + * value only decides colour: nothing about what is shown, sorted, counted or + * stored changes with it, which is why it can be flipped mid-read with + * nothing to save. + */ + theme: ThemeChoice; /** False until the reader confirms or changes the guessed region. */ regionConfirmed: boolean; /** False until the reader has picked their games on first run. */ @@ -116,6 +127,7 @@ function defaults(): Prefs { detectDaily: false, showCompleted: true, showIgnored: false, + theme: DEFAULT_THEME_CHOICE, regionConfirmed: false, onboarded: false, };