diff --git a/AGENTS.md b/AGENTS.md index bae0842..bde2519 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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// 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 395cfe0..00998a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 ``: `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 `` — 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 diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 07e7e95..84ccdda 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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 ``` diff --git a/docs/PRD.md b/docs/PRD.md index c722a50..c64a54c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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