feat: per-user dark mode

✅ merged · #335 · open-legal-products/mike ← duncanmcqueen/mike · opened 25d ago by duncanmcqueen · merged 16d ago by willchen96 · +467-2 across 11 files · ↗ on GitHub

From the PR description

Summary

Adds an opt-in dark mode. Users enable it under Settings > Appearance > Dark Mode; the preference persists on the user profile and applies app-wide on subsequent loads.

This branch has been rebased onto current upstream main and the toggle is integrated with the new /settings page and SettingsSection / SettingsToggle components.

How it works

  • Storage: new dark_mode boolean not null default false column on user_profiles, following the existing per-column preference pattern. schema.sql and 20260813_02_user_dark_mode.sql keep fresh installs and upgraded deployments aligned.
  • API: PATCH /user/profile accepts boolean darkMode; serialized profiles expose it. Older databases without the column fall back to light mode until the migration is applied.
  • Frontend: UserProfileContext toggles the .dark class and color-scheme on the document root. Toggle changes apply immediately while persistence is in flight; failures restore the previous theme.
  • Styling: the .dark palette covers semantic app tokens plus legacy hardcoded Tailwind neutral surfaces (bg-white/*, gray text/borders/divides), avoiding page-by-page forks.

Tests

  • Backend route tests cover persistence/serialization and non-boolean rejection.
  • theme.test.ts covers applying light and dark root state.
  • UserProfileContext.test.tsx covers dark -> light -> dark switching and rollback after a failed save.

Validation completed (rebased onto current main):

  • backend full suite: 709 passed, 24 skipped; tsc --noEmit clean
  • frontend tsc --noEmit clean; eslint 0 errors
  • frontend suite: 470 passed; the dark-mode tests specifically - theme.test.ts (2) and UserProfileContext.test.tsx (2) - all pass, as do the two backend PATCH /user/profile appearance cases

Rebased onto current main

Re-verified the feature has not landed upstream in the meantime: no dark_mode column in schema.sql, no dark-mode migration, no darkMode in routes/user.ts, no lib/theme.ts. (globals.css does contain a .dark block, but that is the stock shadcn token palette - the variant is declared and nothing ever toggles it.)

Two things needed reconciling rather than merging:

Per-column migration fallbacks. main has since added quick_actions_visible, another optional profile column using the same deploy-before-migrate pattern, and both branches edited the same fallback. The fallbacks now cascade per column, so a database that already has quick_actions_visible but not yet dark_mode keeps the former instead of dropping to the shared base select.

Settings restructure. main reorganised /settings and removed the sign-out control this branch's import list still referenced, so only the icons the page actually renders are imported.

The local full frontend suite has four pre-existing Node 26/jsdom Blob.text() failures in untouched mikeApi.test.ts; upstream CI uses its normal fresh Node 22 environment.

Deliberate behavior

  • Explicit toggle only; no prefers-color-scheme detection.
  • A returning dark-mode user may briefly see the light palette before the authenticated profile loads.

Our analysis

Add per-user dark mode — read the full analysis →

Think the analysis missed something the PR description covers?

Commits in this PR (3)

SHA Subject Author Date
00f6ad75 feat: per-user dark mode duncanmcqueen 2026-08-21 ↗ GitHub
commit body
Opt-in dark mode under Settings > Appearance. The preference lives on the
user profile and applies app-wide on subsequent loads.

- Storage: dark_mode boolean not null default false on user_profiles,
  with 20260813_02_user_dark_mode.sql and a matching schema.sql line.
- API: PATCH /user/profile accepts a boolean darkMode; serialized
  profiles expose it.
- Frontend: UserProfileContext toggles the .dark class and color-scheme
  on the document root. Changes apply immediately while the save is in
  flight and roll back if it fails.
- Styling: the .dark palette covers the semantic app tokens plus the
  legacy hardcoded Tailwind neutrals (bg-white/*, gray text, borders,
  divides), so pages did not need forking one by one.

Rebased onto main. Two things had to be reconciled rather than merged:

main added quick_actions_visible, another optional profile column using
the same deploy-before-migrate fallback. The fallbacks are now per column
and cascade - a database that has quick_actions_visible but not yet
dark_mode keeps the former instead of dropping to the shared base select.

main's settings restructure dropped the sign-out control this branch's
import list still carried, so only the icons the page actually renders
are imported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
538c5338 test(theme): cover applyDarkMode's SSR guard duncanmcqueen 2026-08-22 ↗ GitHub
commit body
theme.ts is inside the coverage-gated src/app/lib/** include set, but
its two existing tests both run under jsdom, where `document` is always
defined. The `typeof document === "undefined"` early return was never
taken, leaving theme.ts at 75% branches and pulling the lib layer's
global branch coverage to 96.91% - just under the 97% ratchet floor.

Stub `document` away for a third case so both arms of the guard are
exercised. theme.ts is now 100% across all four metrics and the global
branch figure is back to 97.15%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
defea20c Merge branch 'main' into dark-mode duncanmcqueen 2026-08-22 ↗ GitHub
commit body
Resolves a semantic conflict in backend/src/routes/user.ts, where main
rebuilt selectProfile's deploy-before-migrate cascade (adding the
20260821 onboarding/password tiers and threading cascadeError between
them) while this branch added its own dark_mode tier to the old shape.

Composed both rather than taking either side:

- PROFILE_SELECT is main's full column list plus dark_mode, appended
  last so an older database still fails the full select on one of the
  earlier 20260821 columns and lands on the right tier.
- PROFILE_SELECT_NO_DARK_MODE is exactly that list minus dark_mode, so
  a database missing only dark_mode keeps its live onboarding,
  password and quick-action columns instead of dropping a tier.
- The dark_mode retry sits above main's tiers (newest column first) and
  threads cascadeError the way they do.

main's password/onboarding tiers return rows without dark_mode, which
is safe: serializeProfile reads it as `row.dark_mode === true`, so an
absent column serializes to false.

Verified on Node 22 to match CI: backend build + 746 tests pass;
frontend 561 tests pass with branches at 97.61% (floor 97), lint 0
errors, production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-335.md from inside the repo you want the changes in.

⬇ Download capture-pull-335.md