feat: per-user dark mode
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 falsecolumn onuser_profiles, following the existing per-column preference pattern.schema.sqland20260813_02_user_dark_mode.sqlkeep fresh installs and upgraded deployments aligned. - API:
PATCH /user/profileaccepts booleandarkMode; serialized profiles expose it. Older databases without the column fall back to light mode until the migration is applied. - Frontend:
UserProfileContexttoggles the.darkclass andcolor-schemeon the document root. Toggle changes apply immediately while persistence is in flight; failures restore the previous theme. - Styling: the
.darkpalette 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.tscovers applying light and dark root state.UserProfileContext.test.tsxcovers dark -> light -> dark switching and rollback after a failed save.
Validation completed (rebased onto current main):
- backend full suite: 709 passed, 24 skipped;
tsc --noEmitclean - frontend
tsc --noEmitclean;eslint0 errors - frontend suite: 470 passed; the dark-mode tests specifically -
theme.test.ts(2) andUserProfileContext.test.tsx(2) - all pass, as do the two backendPATCH /user/profile appearancecases
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-schemedetection. - 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 bodyOpt-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 bodytheme.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 bodyResolves 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.