feat: add the light palette, and the module that resolves a theme

The dark tokens re-struck under `[data-theme="light"]`. Nothing sets that
attribute yet, so the app still renders exactly as it did.

Two things could not be tokens. Game hues are data — ours and the
reader's — and every one was picked against a near-black ground, so
`readableHue` darkens them along the same hue until they read on paper,
and returns dark untouched by construction. And the attribute has to be
on the root before React can mount, or a reader who chose light gets a
dark flash on every load, so the shell reads the preference itself.

That leaves the ground colour written in three files that cannot import
each other, which is what the test pins together.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 23:30:26 +02:00
co-authored by Claude Opus 5
parent adab688e0e
commit 448c70968d
7 changed files with 487 additions and 17 deletions
+45 -9
View File
@@ -12,20 +12,56 @@
<meta name="theme-color" content="#12141c" /> <meta name="theme-color" content="#12141c" />
<!-- <!--
This page is already dark. Two signals, because they cover different This page ships dark and offers light; both are drawn deliberately, and
readers: neither wants a third party guessing at one from the other.
color-scheme tells the browser (and well-behaved extensions) that a dark color-scheme states the default rendering, so form controls and scrollbars
rendering is intended, so form controls and scrollbars come out dark and come out dark before any stylesheet has loaded. It is only the default:
nothing tries to synthesise a light theme. styles.css sets the real `color-scheme` property per theme, and that wins.
darkreader-lock is Dark Reader's documented opt-out. Without it Dark darkreader-lock is Dark Reader's documented opt-out, and it still applies
Reader inverts an already-dark palette, which wrecks the urgency ramp — now that there is a light theme — arguably more. The colours in this UI
the colours in this UI carry meaning, so having them re-mapped is not a carry meaning (the urgency ramp, the per-game hues), and an extension
cosmetic problem. re-mapping them breaks the reading, whichever ground it starts from. A
reader who wants light has a control in settings that was designed.
--> -->
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<meta name="darkreader-lock" /> <meta name="darkreader-lock" />
<!--
The theme, before first paint.
Dark is the default and needs nothing: it is what the stylesheet says with
no attribute set. A reader who chose light is the case this exists for —
without it they get a dark page for as long as the bundle takes to arrive
and mount, on every single load, which is a flash of the thing they
switched off.
It reads the same stored key as the app (`state/storage.ts`) and applies
the same attribute (`state/theme.ts`), and it cannot import either, so a
test pins the three copies together. Wrapped in try/catch and doing
nothing on failure: a reader with storage disabled gets the default theme,
never a blank page.
-->
<script>
(function () {
try {
var prefs = JSON.parse(
localStorage.getItem("gacha-tracker:v1:prefs") || "{}",
);
var light =
prefs.theme === "light" ||
(prefs.theme === "system" &&
window.matchMedia("(prefers-color-scheme: light)").matches);
if (!light) return;
document.documentElement.dataset.theme = "light";
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", "#edf0f7");
} catch (e) {
/* No stored preference we can read: the default stands. */
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
+10 -4
View File
@@ -20,8 +20,14 @@ import { readerInstant, type EventDraft } from "../state/useCustom.ts";
* instead of us. * instead of us.
*/ */
/** Enough hues to tell lanes apart, none of them colliding with a tracked game. */ /**
const HUES = [ * Enough hues to tell lanes apart, none of them colliding with a tracked game.
*
* Stored raw, as picked. What a light-mode reader sees is a darkened reading of
* these (`readableHue`), for the same reason the tracked games get one — the
* palette was struck against the dark ground.
*/
export const CUSTOM_HUES = [
"#C74B50", "#C74B50",
"#E08A3C", "#E08A3C",
"#D9C34A", "#D9C34A",
@@ -52,7 +58,7 @@ export function GameForm({
onCancel: () => void; onCancel: () => void;
}) { }) {
const [name, setName] = useState(initial?.name ?? ""); const [name, setName] = useState(initial?.name ?? "");
const [hue, setHue] = useState(initial?.hue ?? HUES[0]!); const [hue, setHue] = useState(initial?.hue ?? CUSTOM_HUES[0]!);
const valid = name.trim().length > 0 && name.trim().length <= 40; const valid = name.trim().length > 0 && name.trim().length <= 40;
return ( return (
@@ -77,7 +83,7 @@ export function GameForm({
<p className={`${labelClass()} mt-3`}>Lane colour</p> <p className={`${labelClass()} mt-3`}>Lane colour</p>
<div className="mt-1.5 flex flex-wrap gap-1.5"> <div className="mt-1.5 flex flex-wrap gap-1.5">
{HUES.map((h) => ( {CUSTOM_HUES.map((h) => (
<button <button
key={h} key={h}
type="button" type="button"
+2 -2
View File
@@ -100,7 +100,7 @@ export function EventDetail({
type="button" type="button"
aria-label="Close details" aria-label="Close details"
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ground/80 backdrop-blur-sm" className="absolute inset-0 bg-scrim backdrop-blur-sm"
/> />
<div <div
role="dialog" role="dialog"
@@ -297,7 +297,7 @@ export function EventDetail({
className={`flex-1 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors ${ className={`flex-1 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors ${
completed completed
? "border-hairline text-muted hover:text-ink" ? "border-hairline text-muted hover:text-ink"
: "border-transparent bg-ink text-ground hover:bg-white" : "border-transparent bg-ink text-ground hover:bg-ink-strong"
}`} }`}
> >
{completed ? "Mark not done" : "Mark done"} {completed ? "Mark not done" : "Mark done"}
+1 -1
View File
@@ -119,7 +119,7 @@ export function Welcome({
type="button" type="button"
disabled={chosen.length === 0} disabled={chosen.length === 0}
onClick={() => onConfirm(chosen, view)} 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 {chosen.length === 0
? "Pick at least one game" ? "Pick at least one game"
+212
View File
@@ -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.** `<meta name="theme-color">` 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
* `<meta name="theme-color">` 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<Theme, string> = {
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;
}
+65 -1
View File
@@ -9,6 +9,10 @@
* Keeping them separate is what lets one glance answer "whose event is this?" * 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 * and "how long have I got?" at the same time. Never colour an urgency element
* with a game hue, or vice versa. * 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 { @theme {
/* Ground is deep blue-black, not neutral near-black — the whole surface /* Ground is deep blue-black, not neutral near-black — the whole surface
@@ -18,8 +22,20 @@
--color-raised: #232739; --color-raised: #232739;
--color-hairline: #2c3145; --color-hairline: #2c3145;
--color-ink: #e9ebf3; --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-muted: #888fa6;
--color-faint: #5a6178; --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. */ /* Heat ramp: monotonic in salience, dim → blue → amber → red. */
--color-calm: #55607f; --color-calm: #55607f;
@@ -31,10 +47,58 @@
--font-body: "Public Sans", ui-sans-serif, system-ui, sans-serif; --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 { :root {
color-scheme: dark; 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, html,
body, body,
#root { #root {
@@ -51,7 +115,7 @@ body {
rather than a flat fill. Subtle enough not to fight the data. */ rather than a flat fill. Subtle enough not to fight the data. */
background-image: radial-gradient( background-image: radial-gradient(
120% 70% at 50% -10%, 120% 70% at 50% -10%,
#1a1e2c 0%, var(--color-glow) 0%,
var(--color-ground) 60% var(--color-ground) 60%
); );
background-attachment: fixed; background-attachment: fixed;
+152
View File
@@ -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);
});
});