#31 — Appearance Settings (Dark Mode) #31

Closed
opened 2026-08-18 13:13:01 +02:00 by lena · 0 comments
lena commented 2026-08-18 13:13:01 +02:00 (Migrated from git.butzei.de)

Story: Appearance Settings (Dark Mode)

As a user,
I want to switch the app between Light, Dark, and System theme,
so that it is comfortable to use in different lighting conditions and matches my OS preference.

Acceptance criteria:

  • The Appearance tab in the settings modal (currently a placeholder) shows three options: Light / Dark / System
  • Default is System (follows the OS/browser prefers-color-scheme setting)
  • The selected theme is applied immediately without a page reload
  • The preference is stored in the user's account and applied on next login across devices
  • All existing UI components are visually correct in dark mode (no unreadable text, no invisible buttons)
  • The theme transition does not produce a flash of the wrong theme on page load

Out of scope for this story:

  • Custom colour themes beyond Light and Dark
  • Per-list or per-todo colour overrides

Blockers: None (the Appearance tab placeholder already exists from #07)

Priority: Could — high satisfaction relative to effort; the tab placeholder is already in place.

Status: Delivered. :root had been a byte-for-byte duplicate of .dark since the original
UI redesign (#42) — the app had never actually had a working light theme. This story wrote a
genuine light OKLCH palette (kept the orange-500 primary/ring accent consistent across both
themes) and added a --success token (mirroring the existing --destructive light/dark split)
after code review found text-green-400 success messages, unchanged since before this story but
never previously exposed to a light background, failed WCAG AA (~1.7:1) against the new white
default.

Backend: Common.Types.Theme (plain enum — TodoListColor/TodoPriority precedent, not a Vogen
VO) stored as a string column on UserEntity; GetThemePreferenceQuery/
UpdateThemePreferenceCommand mirror the IsEmailVerified single-field query/command shape.
Frontend: theme.ts resolves System via prefers-color-scheme and toggles the .dark class
already scaffolded by #42's @custom-variant dark; a blocking inline script in index.html
mirrors that resolution logic to apply the cached choice before first paint (avoiding a flash of
the wrong theme), reconciled against the server once the app mounts.

/code-review (high effort, 8 finder angles + a verification pass) found and fixed: a real race
where the mount-time GetThemePreferenceQuery fetch could resolve after a manual Settings change
and silently revert it (fixed with a themeVersion counter checked before applying a stale
response); localStorage's theme key being device- not account-scoped, so a shared/public browser
would flash the previous account's theme on the next login (fixed by resetting to System
wherever setUserId(null) fires — the one place every logout/session-loss path, including the
403 handler, already funnels through); and the contrast regression above. store.ts's setTheme
now applies the theme itself, folding away the manually-paired setTheme+applyTheme call sites
the review flagged as an unenforced-pairing risk.

Verified live end-to-end (register, toggle Dark/Light with immediate visual effect, confirm the
.dark class is present at domcontentloaded on reload with no FOUC, confirm logout resets the
theme) against the sandbox's db/redis network via a throwaway Playwright script — see
ai/roles/memory/03_backend_engineer_memory.md's #27 entry for the Docker-free live-verification
technique this reused. Backend unit tests for the two new handlers are Testcontainers-backed and
can't run in this Docker-less sandbox, but compile clean and will run for real on the next green
CI pass.

# Story: Appearance Settings (Dark Mode) **As a** user, **I want to** switch the app between Light, Dark, and System theme, **so that** it is comfortable to use in different lighting conditions and matches my OS preference. **Acceptance criteria:** - [ ] The Appearance tab in the settings modal (currently a placeholder) shows three options: Light / Dark / System - [ ] Default is System (follows the OS/browser `prefers-color-scheme` setting) - [ ] The selected theme is applied immediately without a page reload - [ ] The preference is stored in the user's account and applied on next login across devices - [ ] All existing UI components are visually correct in dark mode (no unreadable text, no invisible buttons) - [ ] The theme transition does not produce a flash of the wrong theme on page load **Out of scope for this story:** - Custom colour themes beyond Light and Dark - Per-list or per-todo colour overrides **Blockers:** None (the Appearance tab placeholder already exists from `#07`) **Priority:** Could — high satisfaction relative to effort; the tab placeholder is already in place. **Status:** Delivered. `:root` had been a byte-for-byte duplicate of `.dark` since the original UI redesign (`#42`) — the app had never actually had a working light theme. This story wrote a genuine light OKLCH palette (kept the orange-500 primary/ring accent consistent across both themes) and added a `--success` token (mirroring the existing `--destructive` light/dark split) after code review found `text-green-400` success messages, unchanged since before this story but never previously exposed to a light background, failed WCAG AA (~1.7:1) against the new white default. Backend: `Common.Types.Theme` (plain enum — `TodoListColor`/`TodoPriority` precedent, not a Vogen VO) stored as a `string` column on `UserEntity`; `GetThemePreferenceQuery`/ `UpdateThemePreferenceCommand` mirror the `IsEmailVerified` single-field query/command shape. Frontend: `theme.ts` resolves `System` via `prefers-color-scheme` and toggles the `.dark` class already scaffolded by `#42`'s `@custom-variant dark`; a blocking inline script in `index.html` mirrors that resolution logic to apply the cached choice before first paint (avoiding a flash of the wrong theme), reconciled against the server once the app mounts. `/code-review` (high effort, 8 finder angles + a verification pass) found and fixed: a real race where the mount-time `GetThemePreferenceQuery` fetch could resolve after a manual Settings change and silently revert it (fixed with a `themeVersion` counter checked before applying a stale response); `localStorage`'s theme key being device- not account-scoped, so a shared/public browser would flash the previous account's theme on the next login (fixed by resetting to `System` wherever `setUserId(null)` fires — the one place every logout/session-loss path, including the 403 handler, already funnels through); and the contrast regression above. `store.ts`'s `setTheme` now applies the theme itself, folding away the manually-paired `setTheme`+`applyTheme` call sites the review flagged as an unenforced-pairing risk. Verified live end-to-end (register, toggle Dark/Light with immediate visual effect, confirm the `.dark` class is present at `domcontentloaded` on reload with no FOUC, confirm logout resets the theme) against the sandbox's `db`/`redis` network via a throwaway Playwright script — see `ai/roles/memory/03_backend_engineer_memory.md`'s `#27` entry for the Docker-free live-verification technique this reused. Backend unit tests for the two new handlers are Testcontainers-backed and can't run in this Docker-less sandbox, but compile clean and will run for real on the next green CI pass.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#31
No description provided.