{completed ? "Mark not done" : "Mark done"}
diff --git a/src/client/components/Welcome.tsx b/src/client/components/Welcome.tsx
index 74c6350..78e8164 100644
--- a/src/client/components/Welcome.tsx
+++ b/src/client/components/Welcome.tsx
@@ -119,7 +119,7 @@ export function Welcome({
type="button"
disabled={chosen.length === 0}
onClick={() => onConfirm(chosen, view)}
- className="rounded-xl bg-ink px-5 py-3 text-sm font-semibold text-ground transition-colors duration-150 hover:bg-white disabled:cursor-not-allowed disabled:bg-raised disabled:text-faint"
+ className="rounded-xl bg-ink px-5 py-3 text-sm font-semibold text-ground transition-colors duration-150 hover:bg-ink-strong disabled:cursor-not-allowed disabled:bg-raised disabled:text-faint"
>
{chosen.length === 0
? "Pick at least one game"
diff --git a/src/client/state/theme.ts b/src/client/state/theme.ts
new file mode 100644
index 0000000..7f1c110
--- /dev/null
+++ b/src/client/state/theme.ts
@@ -0,0 +1,212 @@
+import { useEffect, useState } from "react";
+import type { GameMeta } from "../../shared/games.ts";
+
+/**
+ * Which ground the app is drawn on.
+ *
+ * Almost everything about this is settled in CSS: `styles.css` holds the dark
+ * tokens as the defaults and re-strikes them under `[data-theme="light"]`, so
+ * no component ever asks which theme it is in. Three things cannot be settled
+ * there, and they are what this module is:
+ *
+ * - **Resolving the reader's answer.** "System" is a choice about a preference
+ * they set elsewhere, and it has to be read and watched.
+ * - **The game hues.** They are data, not tokens — `games.ts` for the games we
+ * track, the reader's own typing for theirs — and they were all picked
+ * against a near-black ground. On paper the brighter ones are unreadable, so
+ * they are darkened until they are not.
+ * - **The browser's own chrome.** `` is markup, so it
+ * is set from here rather than styled.
+ */
+export type Theme = "dark" | "light";
+
+/**
+ * What the reader chose, which is not the same as what gets drawn: `system`
+ * defers to the device, and the other two override it outright.
+ */
+export type ThemeChoice = Theme | "system";
+
+/**
+ * Dark is the default and stays it.
+ *
+ * Not `system`: this app is a lit instrument panel and that is what it should
+ * be on first sight, and a reader whose OS is in light mode has said something
+ * about their OS rather than about this page. Defaulting to the device would
+ * also silently move every existing reader the first time they load a build
+ * that has this, which is the `knownGames` mistake in a different costume.
+ * Choosing `system` is one tap, and then it *is* their answer.
+ */
+export const DEFAULT_THEME_CHOICE: ThemeChoice = "dark";
+
+/** The media query "system" listens to. */
+export const LIGHT_QUERY = "(prefers-color-scheme: light)";
+
+/**
+ * The `--color-ground` of each theme, duplicated out of `styles.css` because
+ * `` is markup and cannot read a custom property. A
+ * test pins the two copies together; the browser chrome disagreeing with the
+ * page is exactly the kind of drift nobody files a bug about.
+ */
+export const THEME_COLOR: Record = {
+ dark: "#12141c",
+ light: "#edf0f7",
+};
+
+/** What the reader's choice comes to on this device, right now. */
+export function resolveTheme(
+ choice: ThemeChoice,
+ systemPrefersLight: boolean,
+): Theme {
+ if (choice === "system") return systemPrefersLight ? "light" : "dark";
+ return choice;
+}
+
+// ---------------------------------------------------------------------------
+// Game hues on a light ground
+// ---------------------------------------------------------------------------
+
+/**
+ * The smallest contrast a hue may have against the ground it is printed on.
+ *
+ * A hue is a lane label, a chip and a bar border — small text and thin lines,
+ * so this is the 4.5:1 that applies to body copy rather than the 3:1 for large
+ * text.
+ */
+const MIN_HUE_CONTRAST = 4.5;
+
+/** `#abc` and `#aabbcc`, or null for anything else — a hue can be reader data. */
+function parseHex(hex: string): [number, number, number] | null {
+ const body = hex.trim().replace(/^#/, "");
+ const full =
+ body.length === 3
+ ? body
+ .split("")
+ .map((c) => c + c)
+ .join("")
+ : body;
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
+ return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as [
+ number,
+ number,
+ number,
+ ];
+}
+
+function toHex(channels: [number, number, number]): string {
+ return `#${channels.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
+}
+
+/** WCAG relative luminance. */
+function luminance([r, g, b]: [number, number, number]): number {
+ const [lr, lg, lb] = [r, g, b].map((channel) => {
+ const s = channel / 255;
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+ }) as [number, number, number];
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
+}
+
+function contrast(a: [number, number, number], b: [number, number, number]): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [
+ number,
+ number,
+ ];
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+const LIGHT_GROUND = parseHex(THEME_COLOR.light) as [number, number, number];
+
+/**
+ * The same hue, dark enough to read on this theme's ground.
+ *
+ * Dark is returned untouched, always: those hues were chosen against that
+ * ground and every one of them clears the bar there, so adding a theme must
+ * not move a single pixel of the app as it shipped.
+ *
+ * On light it is a scale towards black — the channels keep their ratios, so
+ * Genshin's blue stays Genshin's blue rather than becoming a computed
+ * near-neighbour of Star Rail's. A hue that already reads (Fate's navy) is left
+ * exactly as it is, and so is anything this cannot parse: a reader's stored
+ * colour is not ours to reinterpret when we do not understand it.
+ */
+export function readableHue(hue: string, theme: Theme): string {
+ if (theme === "dark") return hue;
+ const rgb = parseHex(hue);
+ if (rgb === null) return hue;
+ if (contrast(rgb, LIGHT_GROUND) >= MIN_HUE_CONTRAST) return hue;
+
+ // Bisect the scale factor, measuring the colour that will actually be
+ // written: rounding to 8-bit channels after the search would hand back
+ // something a shade lighter than the one that passed, and by a hair's
+ // breadth it can fail the bar it was chosen to clear.
+ const at = (factor: number) =>
+ rgb.map((c) => Math.round(c * factor)) as [number, number, number];
+
+ // Twenty steps lands well inside one channel, and a fixed count keeps this
+ // total — no loop that can fail to terminate on a colour nobody thought of.
+ let tooDark = 0;
+ let tooLight = 1;
+ for (let i = 0; i < 20; i++) {
+ const mid = (tooDark + tooLight) / 2;
+ if (contrast(at(mid), LIGHT_GROUND) >= MIN_HUE_CONTRAST) tooDark = mid;
+ else tooLight = mid;
+ }
+ return toHex(at(tooDark));
+}
+
+/** A lane's metadata with its hue answered for this theme. */
+export function metaOnTheme(meta: GameMeta, theme: Theme): GameMeta {
+ const hue = readableHue(meta.hue, theme);
+ return hue === meta.hue ? meta : { ...meta, hue };
+}
+
+// ---------------------------------------------------------------------------
+// Applying it
+// ---------------------------------------------------------------------------
+
+/**
+ * Put the resolved theme on the document.
+ *
+ * The attribute is what every token override in `styles.css` hangs off, and it
+ * is the same attribute the pre-paint script in `index.html` sets — that script
+ * is what stops a reader who chose light from being shown a dark page for the
+ * length of a bundle download, and this keeps agreeing with it afterwards.
+ */
+export function applyTheme(theme: Theme, doc: Document = document): void {
+ doc.documentElement.dataset["theme"] = theme;
+ const meta = doc.querySelector('meta[name="theme-color"]');
+ if (meta !== null) meta.setAttribute("content", THEME_COLOR[theme]);
+}
+
+function systemPrefersLight(): boolean {
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
+ return false;
+ }
+ return window.matchMedia(LIGHT_QUERY).matches;
+}
+
+/**
+ * The theme this render is in, kept in step with the device and written to the
+ * document.
+ *
+ * The media query is watched whatever the choice is, so a reader on `system`
+ * who flips their OS at sunset sees the page follow without a reload — and one
+ * who has chosen a side is unaffected by it, because `resolveTheme` never asks.
+ */
+export function useTheme(choice: ThemeChoice): Theme {
+ const [prefersLight, setPrefersLight] = useState(systemPrefersLight);
+
+ useEffect(() => {
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
+ return;
+ }
+ const query = window.matchMedia(LIGHT_QUERY);
+ const sync = () => setPrefersLight(query.matches);
+ sync();
+ query.addEventListener("change", sync);
+ return () => query.removeEventListener("change", sync);
+ }, []);
+
+ const theme = resolveTheme(choice, prefersLight);
+ useEffect(() => applyTheme(theme), [theme]);
+ return theme;
+}
diff --git a/src/client/styles.css b/src/client/styles.css
index 05484e3..8b4cf39 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -9,6 +9,10 @@
* Keeping them separate is what lets one glance answer "whose event is this?"
* and "how long have I got?" at the same time. Never colour an urgency element
* with a game hue, or vice versa.
+ *
+ * Heat is tokens, so it is re-struck per theme below. Hue is data and cannot be
+ * — it comes from games.ts and from the reader's own games — so it is read for
+ * the theme in state/theme.ts instead, on its way to the elements that apply it.
*/
@theme {
/* Ground is deep blue-black, not neutral near-black — the whole surface
@@ -18,8 +22,20 @@
--color-raised: #232739;
--color-hairline: #2c3145;
--color-ink: #e9ebf3;
+ /* The far end of `ink`: what a solid ink button goes to under the cursor.
+ A token rather than `white` because on a light ground ink is nearly black,
+ and "brighter" there means darker. */
+ --color-ink-strong: #ffffff;
--color-muted: #888fa6;
--color-faint: #5a6178;
+ /* The veil behind a modal. Carries its own alpha rather than being written
+ `bg-ground/80` at the call site, because the two themes need different
+ amounts of it — see the light block. */
+ --color-scrim: rgb(18 20 28 / 0.8);
+ /* Where the page's light comes from: the top of the body gradient. Lighter
+ than the ground in the dark theme, and lighter still in the light one, so
+ the surface reads as lit from above in both. */
+ --color-glow: #1a1e2c;
/* Heat ramp: monotonic in salience, dim → blue → amber → red. */
--color-calm: #55607f;
@@ -31,10 +47,58 @@
--font-body: "Public Sans", ui-sans-serif, system-ui, sans-serif;
}
+/*
+ * Dark is what this app is, and what it stays unless a reader says otherwise:
+ * the tokens above are the defaults, and light is an override that only applies
+ * once `data-theme="light"` is on the root. Nothing here reads
+ * `prefers-color-scheme` — a reader whose laptop is in light mode has said
+ * something about their laptop, not about this page, and the answer we act on
+ * is the one they gave us (`prefs.theme`, resolved in state/theme.ts).
+ *
+ * Every value below is a *token* override. No component knows which theme it is
+ * in; they all name `ink`, `hairline`, `soon`, and get an answer that suits the
+ * ground they are standing on. The two exceptions, both unavoidable, are the
+ * game hues — reader data, adjusted in state/theme.ts — and the pre-paint
+ * script in index.html.
+ */
:root {
color-scheme: dark;
}
+:root[data-theme="light"] {
+ color-scheme: light;
+
+ /* Paper under daylight, not white: the same cool blue cast as the dark
+ ground, so the heat ramp and the game hues still sit on a neutral that is
+ slightly blue rather than fighting a pure grey. Cards go *up* to white
+ from here, which is the one place the ladder inverts — `raised` is a tint
+ rather than a lift, because on a light ground the way to mark a surface as
+ interactive is to shade it, not to brighten it. */
+ --color-ground: #edf0f7;
+ --color-surface: #ffffff;
+ --color-raised: #dfe4f0;
+ --color-hairline: #c9d1e2;
+ --color-ink: #151824;
+ --color-ink-strong: #000000;
+ --color-muted: #535b73;
+ --color-faint: #767e94;
+ /* Darker than the ground and much thinner than the dark theme's veil: a
+ light scrim over a light page states nothing, and 80% of it would wash the
+ page out rather than push it back. */
+ --color-scrim: rgb(26 31 48 / 0.42);
+ --color-glow: #ffffff;
+
+ /* The ramp again, re-struck for a light ground. Every step keeps its meaning
+ and its ordering; what changes is that each one is now dark enough to read
+ as small text on `ground` (≥ 4.5:1), which the dark theme's values are
+ nowhere near — `soon` in particular is 1.9:1 on paper, and urgency the
+ reader cannot read is not urgency. */
+ --color-calm: #8a92a8;
+ --color-near: #2b62c6;
+ --color-soon: #8a6008;
+ --color-critical: #c81e3c;
+}
+
html,
body,
#root {
@@ -51,7 +115,7 @@ body {
rather than a flat fill. Subtle enough not to fight the data. */
background-image: radial-gradient(
120% 70% at 50% -10%,
- #1a1e2c 0%,
+ var(--color-glow) 0%,
var(--color-ground) 60%
);
background-attachment: fixed;
diff --git a/test/theme.test.ts b/test/theme.test.ts
new file mode 100644
index 0000000..31bcab0
--- /dev/null
+++ b/test/theme.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, test } from "bun:test";
+import { GAMES, metaFor } from "../src/shared/games.ts";
+import { CUSTOM_HUES } from "../src/client/components/CustomForms.tsx";
+import { KEYS } from "../src/client/state/storage.ts";
+import {
+ DEFAULT_THEME_CHOICE,
+ metaOnTheme,
+ readableHue,
+ resolveTheme,
+ THEME_COLOR,
+} from "../src/client/state/theme.ts";
+
+/**
+ * Light mode.
+ *
+ * Three things are worth pinning and one of them is the whole risk. The dark
+ * theme is what this app is and what every hue in `games.ts` was picked
+ * against, so a light theme that moves it has broken something nobody asked to
+ * change. The light theme has to be *readable* — a hue is a lane label at
+ * 10px, and one that washes out on paper takes a game's identity with it. And
+ * the ground colour is written down in three places that cannot import each
+ * other (the stylesheet, this module, the pre-paint script in the shell), so
+ * the copies are checked against each other rather than trusted.
+ */
+
+// An independent implementation of WCAG contrast — deliberately not the one in
+// theme.ts, so this measures the output rather than agreeing with the method.
+function luminance(hex: string): number {
+ const body = hex.replace("#", "");
+ const channels = [0, 2, 4].map((i) => {
+ const s = parseInt(body.slice(i, i + 2), 16) / 255;
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+ }) as [number, number, number];
+ return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [
+ number,
+ number,
+ ];
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+const HUES = [
+ ...Object.values(GAMES).map((game) => game.hue),
+ ...CUSTOM_HUES,
+ // What an unknown lane is drawn in — an import can carry an event whose game
+ // did not come with it.
+ metaFor("mygame:nothing-here", {}).hue,
+];
+
+describe("resolveTheme", () => {
+ test("dark is the default, and it is a side rather than the device's", () => {
+ expect(DEFAULT_THEME_CHOICE).toBe("dark");
+ expect(resolveTheme(DEFAULT_THEME_CHOICE, true)).toBe("dark");
+ });
+
+ test("a reader who picked a side gets it whatever the device says", () => {
+ expect(resolveTheme("dark", true)).toBe("dark");
+ expect(resolveTheme("light", false)).toBe("light");
+ });
+
+ test("system is the device's answer, both ways", () => {
+ expect(resolveTheme("system", true)).toBe("light");
+ expect(resolveTheme("system", false)).toBe("dark");
+ });
+});
+
+describe("readableHue", () => {
+ test("dark is untouched, hue for hue", () => {
+ // The one thing adding a theme must not do is change the theme that was
+ // already there. These colours were chosen against this ground.
+ for (const hue of HUES) expect(readableHue(hue, "dark")).toBe(hue);
+ });
+
+ test("every hue reads on the light ground", () => {
+ for (const hue of HUES) {
+ const adjusted = readableHue(hue, "light");
+ expect(contrast(adjusted, THEME_COLOR.light)).toBeGreaterThanOrEqual(4.5);
+ }
+ });
+
+ test("a hue that already reads is left alone", () => {
+ // Fate's navy is darker than the paper to begin with; darkening it further
+ // would cost a game its identity to fix a problem it does not have.
+ expect(readableHue(GAMES.fgo.hue, "light")).toBe(GAMES.fgo.hue);
+ });
+
+ test("darkening keeps the colour, not just the contrast", () => {
+ // Wuthering Waves is green before and after. A hue is an identity, so a
+ // legibility fix that turned every lane into the same slate would be worse
+ // than the illegibility.
+ const green = readableHue(GAMES.wuwa.hue, "light");
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(green.slice(i, i + 2), 16));
+ expect(g).toBeGreaterThan(r!);
+ expect(g).toBeGreaterThan(b!);
+ });
+
+ test("a colour we cannot read is not reinterpreted", () => {
+ // Custom hues are stored in the reader's browser and could be anything.
+ // Guessing at one we do not understand is worse than leaving it.
+ for (const odd of ["", "rebeccapurple", "var(--color-ink)", "#12345"]) {
+ expect(readableHue(odd, "light")).toBe(odd);
+ }
+ });
+
+ test("shorthand hex is understood rather than passed through", () => {
+ expect(readableHue("#3d6", "light")).toBe(readableHue("#33dd66", "light"));
+ });
+});
+
+describe("metaOnTheme", () => {
+ const meta = GAMES.wuwa;
+
+ test("names and clocks are untouched; only the hue is answered", () => {
+ const light = metaOnTheme(meta, "light");
+ expect(light.hue).not.toBe(meta.hue);
+ expect({ ...light, hue: meta.hue }).toEqual(meta);
+ });
+
+ test("the dark answer is the same object, so nothing re-renders for it", () => {
+ expect(metaOnTheme(meta, "dark")).toBe(meta);
+ });
+});
+
+describe("the ground colour, in all three places it is written down", () => {
+ test("the stylesheet and the module agree", async () => {
+ const css = await Bun.file(
+ new URL("../src/client/styles.css", import.meta.url),
+ ).text();
+
+ // The dark value is in @theme, the light one under the attribute selector.
+ const dark = /@theme\s*\{[^}]*?--color-ground:\s*([^;]+);/s.exec(css);
+ const light =
+ /\[data-theme="light"\]\s*\{[^}]*?--color-ground:\s*([^;]+);/s.exec(css);
+ expect(dark?.[1]?.trim()).toBe(THEME_COLOR.dark);
+ expect(light?.[1]?.trim()).toBe(THEME_COLOR.light);
+ });
+
+ test("the shell paints the right ground before the bundle arrives", async () => {
+ const shell = await Bun.file(
+ new URL("../index.html", import.meta.url),
+ ).text();
+
+ // The pre-paint script is the only reason a reader on light does not get a
+ // dark flash on every load, and it cannot import any of this.
+ expect(shell).toContain(KEYS.prefs);
+ expect(shell).toContain(THEME_COLOR.dark);
+ expect(shell).toContain(THEME_COLOR.light);
+ });
+});