Skip to content

feat: add three-state dark mode - #1

Open
hitpopdimestop wants to merge 23 commits into
mainfrom
feat/dark-mode
Open

feat: add three-state dark mode#1
hitpopdimestop wants to merge 23 commits into
mainfrom
feat/dark-mode

Conversation

@hitpopdimestop

Copy link
Copy Markdown
Owner

Adds a three-state (system / light / dark) theme to the Subscription Billing Runner, with no flash before hydration and cross-tab synchronization.

What changed

  • Spec first. docs/frontend.md gains a ## Theme section defining the preference values, the subscriptions:theme storage key, the always-concrete data-theme attribute, the pre-paint requirement, cross-tab propagation, and the explicit non-goal that theme never touches SSE, the store, or any server route.
  • src/features/theme/ (new): theme.ts (pure logic — parse/resolve/cycle), theme-script.tsx (blocking inline <head> script), use-theme.ts (client hook), theme-toggle.tsx (three-state control in the dashboard header).
  • Semantic tokens in src/app/globals.css: light values on :root, dark on [data-theme="dark"], exposed as Tailwind utilities via @theme inline. Intermediate names (--border-subtle) avoid colliding with Tailwind's namespace.
  • Full migration of the ~140 hardcoded color utilities across constants.ts, formatters.ts, the 9 dashboard components, and dashboard.tsx. The transaction-card enter-flash keyframe moved off raw hex and unified on emerald.

Design decisions

The token collapse is intentionally lossy. text-slate-500/600/700 all became content-muted; bg-amber-50 and bg-amber-100 both became warn-surface. Some elements shift by one shade. That is the point of a token system — no extra tokens were invented to preserve every original shade.

slate-950 was disambiguated by role, not find-and-replace. focus:border-slate-950 on inputs is a focus affordance → border-strong. border-slate-950 bg-slate-950 text-white on selected/primary controls is an inverted surface → surface-inverted / content-inverted.

hover:bg-slate-800 on the primary button became hover:opacity-90, since a single darker-slate hover does not invert correctly across both themes.

Deviations from the plan — please read

1. The plan's theme script alone did not survive hydration. Added a fix (aa61c3f).

The plan assumed suppressHydrationWarning on <html> would let the inline script's data-theme stand. It does not: React reconciles the server-rendered data-theme="light" back onto <html> during hydration, and suppressHydrationWarning only suppresses the console warning, not the attribute patch. Because use-theme.ts as specified called applyPreference only when the preference was system (or on a storage event), an explicit light/dark preference was silently reverted on every load.

Measured with Playwright against a production build, stored preference dark:

before fix:   at commit: dark   after load: light
after fix:    at commit: dark   after load: dark

Fix: the mount effect in use-theme.ts now re-asserts applyPreference(preference) for every preference before deciding whether to subscribe to the media query. This is the same approach next-themes uses. The pre-paint guarantee is unaffected — the inline script still wins the initial paint, which the waitUntil: "commit" e2e assertion covers.

2. Added one token the plan's table omitted: --color-scrim.

The pause dialog backdrop is bg-slate-950/45 — a third meaning of slate-950 that the Migration Hazards section did not list. Applying the table literally (surface-inverted) turned the modal scrim into a light wash over the dark page: the backdrop brightened instead of dimming. A scrim must darken in both themes, so it cannot invert. --color-scrim is defined once on :root as var(--color-slate-950) with no dark override. This is a distinct semantic role, not a shade-preservation token.

3. vitest.setup.ts needed a matchMedia stub.

jsdom does not implement window.matchMedia, so mounting the header in dashboard.test.tsx threw once ThemeToggle was added. Added a guarded light-mode stub (only applied when window exists and the API is missing).

4. Updated an existing assertion in dashboard.test.tsx.

It asserted the literal classes bg-amber-50 / bg-slate-50 on transaction cards; retargeted to bg-warn-surface / bg-surface-muted.

5. Collapsed a now-redundant ternary on the pause dialog's custom-seconds input, whose two branches became character-identical after migration.

Contrast pass (Task 9)

Computed WCAG ratios from the actual Tailwind oklch palette rather than estimating:

Pair (dark) Ratio
content-muted on surface (slate-400/slate-900) 6.79:1 AA
content-subtle on surface (slate-500/slate-900) 3.74:1 AA (secondary/large)
content on surface 16.28:1 AA
warn / success / danger content on their surfaces 8.15–12.03:1 AA

No token values were changed. The plan's conditional remedy (lightening content-subtle and shifting content-muted to slate-300) was not triggered: content-subtle is used only for secondary text and a decorative dot separator, and 3.74:1 clears the 3:1 bar the plan set for secondary text. content-muted at 6.79:1 clears 4.5:1 comfortably.

Pre-existing issue, not introduced here and not fixed here: in light mode, content-subtle on surface (slate-400 on white) is 2.63:1, below even the 3:1 secondary bar. This is the original text-slate-400 in transactions-panel.tsx, carried over unchanged so light mode keeps its current appearance. Worth a follow-up.

Verification

  • yarn test — 62 passed, including 8 new in theme.test.ts
  • yarn test:e2e — 7 passed, including 3 new theme specs (persistence across reload, pre-hydration application, cross-tab sync)
  • yarn build and yarn lint clean
  • Palette-utility grep over src/ returns nothing; no raw hex in src/
  • No new dependencies; no tailwind.config.js
  • grep -i theme over src/server/, src/shared/, src/app/api/ returns nothing — theme never reaches SSE, the store, or a server route
  • Visually checked in a production build, both themes: subscription cards, pause dialog, create form, notices, transaction feed including the flash animation, and the danger/cancel controls

🤖 Generated with Claude Code

Adds a --color-scrim token: the modal backdrop is a third meaning of
slate-950 that must stay dark in both themes, so it cannot reuse
surface-inverted.
React reconciles the server-rendered data-theme onto <html>, discarding
what the blocking inline script wrote; suppressHydrationWarning does not
prevent the attribute patch.
The inline script traded two defects for the flash it prevented: React 19
errors on any <script> rendered in a component, and suppressHydrationWarning
does not cascade, so the toggle's lucide icon still mismatched between server
and client. Both appeared on every load with an explicit preference, and both
were invisible to the previous verification because it ran a production build.

Theming now runs on color-scheme. :root declares `light dark` and every token
is defined once with light-dark(), so the OS setting is honoured during initial
parse with no JavaScript at all. An explicit choice sets data-theme, which
narrows color-scheme to one value; `system` is the absence of the attribute.
This also lets the browser theme its own canvas, scrollbars and form controls.

useSyncExternalStore reports `system` for the hydrating render and the stored
value afterwards, which removes the hydration mismatch by construction rather
than silencing it, and replaces both the mounted flag and the manual storage
listener. resolveTheme is gone; CSS owns resolution now.

Visitors who override their OS setting see it applied after hydration, with a
transition. Every application animates, so there is no special case.

Adds an e2e guard asserting zero console errors in all three preferences —
the coverage whose absence let both defects through.
The inline script traded two defects for the flash it prevented: React 19
errors on any <script> rendered in a component, and suppressHydrationWarning
does not cascade, so the toggle's lucide icon still mismatched between server
and client. Both appeared on every load with an explicit preference, and both
were invisible to the previous verification because it ran a production build.

Theming now runs on color-scheme. :root declares `light dark` and every token
is defined once with light-dark(), so the OS setting is honoured during initial
parse with no JavaScript at all. An explicit choice sets data-theme, which
narrows color-scheme to one value; `system` is the absence of the attribute.
This also lets the browser theme its own canvas, scrollbars and form controls.

useSyncExternalStore reports `system` for the hydrating render and the stored
value afterwards, which removes the hydration mismatch by construction rather
than silencing it, and replaces both the mounted flag and the manual storage
listener. resolveTheme is gone; CSS owns resolution now.

The switch is instant. A CSS transition cannot animate it — light-dark()
follows color-scheme, which is not animatable, so the colours change
discretely and nothing interpolates. A view transition did animate it but
brought aborted-transition errors and unhandled rejections from its skipped
promises, and the result was not perceptible in practice. next-themes ships
disableTransitionOnChange for much the same reasons.

Adds an e2e guard asserting zero console errors in all three preferences —
the coverage whose absence let the original defects through.
The README still advertised a pre-paint inline script, which no longer
exists. Replace it with what the module actually does, and note that
resolution belongs to CSS rather than this code.

architecture-overview.md enumerates the codebase by runtime ownership and
was missing src/features/theme/ entirely. Record it, and why it sits outside
the dashboard's client-state layers.

testing-strategy.md specifies required coverage, so add the theme cases the
implementation now carries — notably the console-error assertion, which is
the only place a hydration mismatch is visible.
The theme module was written in a far more annotated register than the
rest of the code, which carries almost no comments. Keep only the notes
covering genuinely non-obvious contracts — the server snapshot pinning
hydration to `system`, `system` meaning the attribute's absence, `storage`
firing only in other tabs, and why a console-error test exists — and drop
the rest.
The subtle-text contrast fix left --content-subtle and --content-muted
resolving to the same value in both themes, so the vocabulary claimed a
distinction the UI no longer made. Merge them: the three usage sites read
as de-emphasised through size, casing and letterspacing rather than colour,
which the rendered output confirms.

Split e2e/contrast.ts by dependency. The WCAG maths is pure and now lives in
test-utils/ with a vitest suite covering non-sRGB rejection, alpha handling
and symmetry; renderedColors keeps the Playwright Locator work. Previously
its unit test sat in the Playwright suite, so asserting a pure function threw
required a full production build first.

Drop the committed superpowers plan: this project predates that workflow and
should not mix conventions.
The strategy specifies required coverage, so the cases the theme work added
belong in it: the session fallback when storage writes fail, a cross-tab
change applied when reads fail, and AA contrast for helper text on normal
and inverted surfaces.

Also record two conventions the contrast work established — normalizing to
sRGB before measuring, since getComputedStyle now returns lab()/oklch() and
digit-scraping a non-sRGB string yields a confident wrong ratio, and keeping
test tooling split so a pure assertion never requires a production build.
The rule read as an unexplained prohibition, which made it look obvious
while hiding the reason it needed stating. Cross-tab sync everywhere else
in this app goes through SSE, so reusing that machinery for theme is the
tempting move — and it would be wrong, because the store is a single
process-wide singleton whose broadcast reaches every connected visitor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant