docs: write down the second ground, and the rule it creates

PRD gets F15 and the fork that asked for it, DATA-MODEL the prefs field
and the fact that a script outside the bundle now reads that key, and
ARCHITECTURE the pre-paint script — the one piece of this that is neither
CSS nor React.

The AGENTS rule is the part worth reading twice: a component that names a
colour instead of a token now looks right in one theme and wrong in the
other, and nothing will tell you which.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 23:30:41 +02:00
co-authored by Claude Opus 5
parent eca5ad7a0a
commit c1cd1148d9
4 changed files with 70 additions and 3 deletions
+14 -1
View File
@@ -115,9 +115,10 @@ src/client/ React app, service worker, manifest
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
zoom.ts — the timeline's scale ladder; pure
lanes.ts — how the timeline stacks: a lane per game, or one deadline queue; pure
theme.ts — dark or light, and what a game hue reads as on each
scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health
test/ 607 tests
test/ 620 tests
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
snapshots/ current page per source, rewritten by refresh — see its README
```
@@ -510,6 +511,18 @@ to an open page). Four things hold it up:
- Keep old fixtures when a source changes shape — the old one is the regression test proving the
parser still handles the previous format. Fixtures are pinned and permanent; `snapshots/` is the
current page and gets overwritten. Do not conflate them.
- **Colour is a token, and every token has two answers.** The app ships dark and offers light
(PRD F15), and the whole difference is a set of custom properties re-struck under
`:root[data-theme="light"]` in `styles.css`. So a component names `ink`, `hairline` or `soon` and
never a colour: a literal — `bg-white`, a hex, a hardcoded scrim — is a component that looks
right in one theme and wrong in the other, and nothing will tell you which. The two things that
genuinely cannot be tokens are the game hues, which are *data* (`games.ts`, and the reader's own),
and the pre-paint script in `index.html`; both are handled in `src/client/state/theme.ts` and
pinned by `test/theme.test.ts`.
Two rules follow. **The heat ramp must stay readable on whichever ground it is on** — it carries
meaning, and the dark theme's amber is 1.9:1 on paper, so each step is re-struck rather than
reused. And **the dark theme does not move**: `readableHue` returns dark untouched by
construction, because adding a theme is not a licence to redraw the one that shipped.
- **The page is two columns past `lg`, and the split is the one below.** What the page *tells* the
reader to do — the next deadlines, tonight's dailies — pins to a rail on the left and stays put
while the lists it *shows* them scroll beside it. Below that breakpoint it is one column in the
+20 -1
View File
@@ -120,7 +120,8 @@ src/
useMarkSet.ts ignores (and the superseded completions shape)
useProgress.ts status, effort, note, daily override (F12)
useDailyLog.ts which game-days are ticked off
usePrefs.ts region, filters, focus, view, onboarding flags
usePrefs.ts region, filters, focus, view, theme, onboarding flags
theme.ts dark/light: resolving it, applying it, hues on paper
useCustom.ts the reader's own games and events (F13)
gameMeta.tsx lane id → name, label, hue; resolves custom lanes too
sort.ts deadline order, or what you're partway through
@@ -239,6 +240,24 @@ back to the last copy seen). Countdowns run off the device clock, so the app sta
network. Offline state is surfaced in the header and above the footer — stale data must never be
presented as current.
### The theme, before the bundle arrives
The page is drawn dark by default and light when the reader has asked for it (PRD F15). Which one
is decided by one attribute on `<html>`: `styles.css` holds the dark tokens on `:root` and
re-strikes them under `:root[data-theme="light"]`, so nothing in React knows a theme exists and no
component holds a colour of its own.
Setting that attribute is the one part React cannot do in time. It mounts after the bundle has
downloaded and parsed, which on a cold cache is long enough to show a reader who chose light a
dark page, on every single load. So a small inline script in `index.html` reads the same
`localStorage` prefs key the app does, sets the same attribute, and updates `<meta
name="theme-color">` — before first paint, and with a `try`/`catch` so storage being unavailable
costs the reader the default theme rather than the page.
That makes the theme's ground colour a fact written in three files that cannot import each other:
the stylesheet, `state/theme.ts` (which needs it for the meta tag), and the shell. `test/theme.test.ts`
pins the three together rather than trusting them to be edited at the same time.
### Shipping a new version to an open page
A cache-first shell is what makes the offline story work and what makes a deploy invisible: the
+5 -1
View File
@@ -219,7 +219,7 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
"gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this"
"gacha-tracker:v1:prefs" // { region, hiddenGames[], knownGames[]?, focusGame, sort, view,
// timelineDayWidth, timelineGroup, detectDaily, showCompleted,
// showIgnored, regionConfirmed, onboarded }
// showIgnored, theme, regionConfirmed, onboarded }
// timelineDayWidth is px per day on the board, stored as the
// measurement rather than a step number and read through
// snapDayWidth — so a value from an older ladder still opens
@@ -230,6 +230,10 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
// knownGames is every lane the reader has been offered. Absent
// means unrecorded, not "offered nothing" — see PRD F8; a lane
// missing from it is new to them and arrives switched off.
// theme is "dark" | "light" | "system", defaulting to dark — see
// PRD F15. It is read by the app *and* by a pre-paint script in
// index.html, which is the only thing outside the client bundle
// that touches a key in this space.
"gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written
```
+31
View File
@@ -284,6 +284,37 @@ The footer shows when the feed was last updated, per game. If a game's data is m
stale, its lane carries a warning badge. Never present stale data as current — the whole value
proposition is trust in the dates.
**F15 — Light mode, with dark still the default.**
The app is a lit instrument panel and that is what it should be on first sight, but it is also read
on a train in daylight and by people who find a dark UI harder rather than moodier. A public fork
worked a theme toggle out first and is credited for the idea in the colophon. So the ground is
the reader's choice: **Dark**, **Light**, or **System** to follow the device, in settings next to the
region — both are "how do I read this?" and neither changes what the page knows.
Dark is what it ships as, and deliberately not `System`. A reader whose laptop is in light mode has
said something about their laptop, not about this page; defaulting to the device would also move
every existing reader the first time they loaded a build that had this, which is the `knownGames`
mistake in a different costume (F8). Choosing `System` is one tap, and from then on it *is* their
answer.
Three things the light theme has to get right, because they are what a second palette usually gets
wrong:
- **It is a re-strike, not an inversion.** Every colour in the UI is a token, and light redefines the
tokens rather than adding a second set of rules to components. Nothing in the app asks which theme
it is in, so a new component cannot forget to support one.
- **Urgency still reads.** The heat ramp carries meaning, and the dark theme's amber is 1.9:1 on
paper — an urgency the reader cannot read is not urgency. Every step is re-struck to clear 4.5:1
against the light ground, keeping its order and its meaning.
- **A game keeps its colour.** The hues are identity (F1) and were all picked against a near-black
ground; on paper the bright ones vanish. They are darkened until they read, along the same hue, so
Wuthering Waves is still the green one — including the hues a reader picked for a game they
invented (F13).
Switching is instant, costs nothing and saves nothing: no reload, and nothing marked, typed or
ticked is touched. And it survives the load it is chosen on — the shell sets the theme before first
paint, so a reader on light is never shown a dark page while the bundle downloads.
## Out of scope for v1
Accounts and sync; push notifications; in-game resource or pull tracking; native mobile apps (the