feat(portal-shell): light / dark / auto theme switcher #86

Merged
julien merged 1 commits from feat/portal-shell/theme-toggle into main 2026-05-11 03:01:12 +02:00
Owner

Summary

  • Add a header dropdown letting users pick light, dark, or auto (follow the OS) color schemes. Choice persists in localStorage, applies on next reload, and reacts live to OS theme changes when in auto.
  • Built on @angular/cdk/menu for accessible roving focus, escape-to-close, and proper menuitemradio semantics on the three options.
  • Apply dark: variants across the shell (header, sidebar, main bg) and the existing two pages. No semantic-token refactor yet — that belongs in a future ADR (--color-surface-1, --color-text-1, …).

Architecture

  • LayoutStateService grows a themeMode: signal<'light'|'dark'|'auto'> alongside the existing sidebarCollapsed, plus an effectiveTheme computed that resolves auto against the system preference. A side-effect toggles the .dark class on <html>, so every dark: Tailwind utility flips at once.
  • Tailwind v4 dark mode is rewired to class-based via @custom-variant dark (&:where(.dark, .dark *)); in styles.css. This overrides the v4 default (prefers-color-scheme only) so the user's explicit override wins over the OS preference.
  • The switcher trigger reflects the selected mode (sun / moon / monitor), not the effective theme — so users can tell which mode they're in even when auto happens to resolve to the same scheme as a manual pick.

Side-edits in the same PR (already validated in the chat)

  • Logo asset. Replace apf-small.svg (94 kB — a base64-PNG wrapped in SVG markup, not actually vector) with apf-small.png (144×144, 7.6 kB after sharp --kernel lanczos3 --compressionLevel 9). Header swaps to the PNG. The wide vector apf-portal.svg stays around for future surfaces that want the horizontal lockup.
  • Revert FR strings that crept into the header template — project rule (CLAUDE.md) is English-only for source artefacts; FR localisation will happen properly via @angular/localize (separate ADR).

Decisions worth flagging

  • Dropdown over segmented control. The CDK Menu pays its weight: accessible by default (proper aria-haspopup, focus management, ESC handling, click-outside dismissal), reusable for future header menus (user, language, notifications), and one tidy primitive rather than three competing buttons.
  • auto is the default, not light. Most users have an OS-level preference already; respecting it is the least surprising baseline.
  • <html>.dark class lives at the root, not on <app-root>. That's the Tailwind convention and it means CDK overlay popups (the menu itself, future dialogs) inherit the right theme without extra wiring.
  • Bundle delta +21 kB gzip (100 → 121 kB initial). All of it is @angular/cdk/{menu,overlay,a11y} and the dark CSS rules. We stay well under the 300 kB budget. The CDK is already on the architecture menu (ADR-0016 — UI stack: Angular CDK + TailwindCSS) so this is on-strategy spend.
  • No semantic tokens yet. The dark variants use raw Tailwind gray ramps (dark:bg-gray-900, etc.) instead of a --color-surface-1 / --color-text-1 token layer. That keeps the change tractable for now; promotion to semantic tokens deserves its own ADR with the design team in the loop.

Accessibility (ADR-0016)

  • Menu trigger has aria-haspopup="menu", aria-label announcing the current mode + "(open menu)".
  • Menu uses role="menu", items use role="menuitemradio" with aria-checked — assistive tech announces the selection state correctly.
  • All interactive controls keep the 44×44 px touch target.
  • prefers-reduced-motion: reduce already covered by the sidebar transitions; theme switcher has no animations of its own.
  • Contrast: dark surfaces are gray-900 + gray-800 border / gray-100 text — passes WCAG AA. Brand primary shifted to the 300 step in dark mode so the active states keep contrast against gray-900.

What this PR explicitly does NOT do

  • Tokenise the palette into semantic surface / text / border roles (next iteration, ADR-led).
  • Localise UI strings (separate @angular/localize ADR + PR).
  • Animate the theme transition (FOIT-style flicker on toggle is acceptable in v1; we can soften later with a color-scheme CSS transition if it bothers users).

Test plan

  • pnpm exec nx run-many -t lint test build --projects=portal-shell — green (33 / 33 specs, +8 for the theme work).
  • Production build: 121 kB gzip initial (was 100 kB). Under the 300 kB budget.
  • Manual: toggle each of the three modes → header / sidebar / main / cards switch surface colors instantly; trigger glyph updates.
  • Manual: pick auto, change OS theme → UI follows live (Chrome DevTools → Rendering → "Emulate CSS media feature prefers-color-scheme").
  • Manual: reload after each pick → the chosen mode is restored.
  • Manual: keyboard the trigger → ENTER opens menu, arrow keys navigate, ENTER selects, ESC closes; focus returns to the trigger on close.
  • Manual: Lighthouse accessibility on / in dark mode — score unchanged from light mode.
## Summary - Add a header dropdown letting users pick **light**, **dark**, or **auto** (follow the OS) color schemes. Choice persists in `localStorage`, applies on next reload, and reacts live to OS theme changes when in `auto`. - Built on `@angular/cdk/menu` for accessible roving focus, escape-to-close, and proper `menuitemradio` semantics on the three options. - Apply `dark:` variants across the shell (header, sidebar, main bg) and the existing two pages. No semantic-token refactor yet — that belongs in a future ADR (`--color-surface-1`, `--color-text-1`, …). ## Architecture - [`LayoutStateService`](apps/portal-shell/src/app/state/layout-state.service.ts) grows a `themeMode: signal<'light'|'dark'|'auto'>` alongside the existing `sidebarCollapsed`, plus an `effectiveTheme` computed that resolves `auto` against the system preference. A side-effect toggles the `.dark` class on `<html>`, so every `dark:` Tailwind utility flips at once. - Tailwind v4 dark mode is rewired to **class-based** via `@custom-variant dark (&:where(.dark, .dark *));` in `styles.css`. This overrides the v4 default (`prefers-color-scheme` only) so the user's explicit override wins over the OS preference. - The switcher trigger reflects the **selected** mode (sun / moon / monitor), not the effective theme — so users can tell which mode they're in even when `auto` happens to resolve to the same scheme as a manual pick. ## Side-edits in the same PR (already validated in the chat) - **Logo asset.** Replace `apf-small.svg` (94 kB — a base64-PNG wrapped in SVG markup, not actually vector) with `apf-small.png` (144×144, 7.6 kB after `sharp --kernel lanczos3 --compressionLevel 9`). Header swaps to the PNG. The wide vector `apf-portal.svg` stays around for future surfaces that want the horizontal lockup. - **Revert FR strings** that crept into the header template — project rule (CLAUDE.md) is English-only for source artefacts; FR localisation will happen properly via `@angular/localize` (separate ADR). ## Decisions worth flagging - **Dropdown over segmented control.** The CDK Menu pays its weight: accessible by default (proper `aria-haspopup`, focus management, ESC handling, click-outside dismissal), reusable for future header menus (user, language, notifications), and one tidy primitive rather than three competing buttons. - **`auto` is the default,** not `light`. Most users have an OS-level preference already; respecting it is the least surprising baseline. - **`<html>.dark` class lives at the root,** not on `<app-root>`. That's the Tailwind convention and it means CDK overlay popups (the menu itself, future dialogs) inherit the right theme without extra wiring. - **Bundle delta +21 kB gzip** (100 → 121 kB initial). All of it is `@angular/cdk/{menu,overlay,a11y}` and the dark CSS rules. We stay well under the 300 kB budget. The CDK is already on the architecture menu (ADR-0016 — *UI stack: Angular CDK + TailwindCSS*) so this is on-strategy spend. - **No semantic tokens yet.** The dark variants use raw Tailwind gray ramps (`dark:bg-gray-900`, etc.) instead of a `--color-surface-1` / `--color-text-1` token layer. That keeps the change tractable for now; promotion to semantic tokens deserves its own ADR with the design team in the loop. ## Accessibility (ADR-0016) - Menu trigger has `aria-haspopup="menu"`, `aria-label` announcing the current mode + "(open menu)". - Menu uses `role="menu"`, items use `role="menuitemradio"` with `aria-checked` — assistive tech announces the selection state correctly. - All interactive controls keep the 44×44 px touch target. - `prefers-reduced-motion: reduce` already covered by the sidebar transitions; theme switcher has no animations of its own. - Contrast: dark surfaces are gray-900 + gray-800 border / gray-100 text — passes WCAG AA. Brand primary shifted to the `300` step in dark mode so the active states keep contrast against gray-900. ## What this PR explicitly does NOT do - Tokenise the palette into semantic surface / text / border roles (next iteration, ADR-led). - Localise UI strings (separate `@angular/localize` ADR + PR). - Animate the theme transition (FOIT-style flicker on toggle is acceptable in v1; we can soften later with a `color-scheme` CSS transition if it bothers users). ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (**33 / 33 specs**, +8 for the theme work). - [x] Production build: **121 kB gzip initial** (was 100 kB). Under the 300 kB budget. - [x] Manual: toggle each of the three modes → header / sidebar / main / cards switch surface colors instantly; trigger glyph updates. - [x] Manual: pick `auto`, change OS theme → UI follows live (Chrome DevTools → Rendering → "Emulate CSS media feature prefers-color-scheme"). - [x] Manual: reload after each pick → the chosen mode is restored. - [x] Manual: keyboard the trigger → ENTER opens menu, arrow keys navigate, ENTER selects, ESC closes; focus returns to the trigger on close. - [x] Manual: Lighthouse accessibility on `/` in dark mode — score unchanged from light mode.
julien added 1 commit 2026-05-11 03:00:53 +02:00
feat(portal-shell): light / dark / auto theme switcher
CI / commits (pull_request) Successful in 3m11s
CI / scan (pull_request) Successful in 3m11s
CI / check (pull_request) Successful in 3m27s
CI / a11y (pull_request) Successful in 2m8s
CI / perf (pull_request) Successful in 4m14s
f6ba6cde2c
Add a header dropdown that lets users pick light, dark, or auto (follow
the OS) color schemes. The choice persists in localStorage and applies
across reloads. In `auto` mode, the service listens to the
`prefers-color-scheme` media query so the UI flips when the OS theme
changes without a page refresh.

Architecture:

- `LayoutStateService` grows a `themeMode` signal alongside the
  existing `sidebarCollapsed`, plus an `effectiveTheme` computed that
  resolves `auto` against the system preference. A side-effect toggles
  the `.dark` class on `<html>` so every `dark:` Tailwind utility
  flips at once.
- Tailwind v4 dark mode rewired to class-based via
  `@custom-variant dark (&:where(.dark, .dark *));` in `styles.css`,
  overriding the v4 default (media-query-driven).
- Theme switcher built on `@angular/cdk/menu` for accessible roving
  focus, escape/click-outside dismissal, and `menuitemradio` semantics
  on the three options. Trigger glyph reflects the SELECTED mode (sun
  / moon / monitor) so users can tell which mode they're in even when
  auto resolves to the same scheme as a manual pick.
- Dark variants applied across the shell (header, sidebar, main bg)
  and the existing two pages. No semantic-token refactor yet — that
  belongs in a future ADR (`--color-surface-1`, ...).

Side-edits:

- Replace `apf-small.svg` (94 kB, a base64-PNG-wrapped-in-SVG) with
  a 144x144 PNG optimised by sharp at compression level 9 (7.6 kB).
- Header logo `src` swapped to `apf-small.png`. The wide vector
  `apf-portal.svg` stays in place for future surfaces that want the
  horizontal lockup.
julien merged commit 0ae7e0e23d into main 2026-05-11 03:01:12 +02:00
julien deleted branch feat/portal-shell/theme-toggle 2026-05-11 03:01:15 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#86