duncanmcqueen gives Mike a deliberate dark mode

Users can now choose a darker workspace and have that choice follow them when they return.

branding

duncanmcqueen has added an opt-in dark mode through Mike's settings, making it a personal preference rather than a guess based on a device's system settings.

The change takes effect across the application as soon as a user switches it on, then saves quietly to their profile for future sessions. If that save does not work, the interface returns to its previous appearance rather than leaving the user with an unreliable setting. The work also accounts for staged upgrades, so older deployments stay usable while the underlying profile update is rolled out.

This is a modest but well-finished usability move: one consistent darker interface, with the user's choice in control.

So what Teams that spend long stretches reviewing matters or working late will care because a familiar legal workspace now adapts to individual working preferences without creating a separate product experience.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

3 commits from duncanmcqueen/mike, oldest first. Source extracted verbatim from the harvested git log.

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 thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-1263.md from inside the repo you want the changes in.

⬇ Download capture-thread-1263.md