From 3ad74df5ea53f7709fee3749486cebf6c2b7403d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 18 Aug 2026 23:30:41 +0200 Subject: [PATCH] feat: let a stored preference decide the ground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prefs.theme` is dark, light or system, defaulting to dark: a reader whose laptop is in light mode has said something about their laptop, not about this page, and following the device would move every existing reader the first time they loaded this. No control yet — the value can only arrive from storage. App resolves it and writes it to the document. It is also where a hue meets the theme: doing that in the lane resolver means every label, chip, rail and bar gets the readable answer without a component knowing a theme exists, which is why the colophon moves onto that resolver too. Co-Authored-By: Claude Opus 5 (1M context) --- src/client/App.tsx | 15 +++++++-- src/client/components/Colophon.tsx | 50 +++++++++++++++++------------- src/client/state/usePrefs.ts | 12 +++++++ 3 files changed, 53 insertions(+), 24 deletions(-) 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, };