From 10c80f189d28e29b62e5696b7a2afd5da4720bc5 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 16 May 2026 02:52:53 +0200 Subject: [PATCH] feat(portal-shell): align theme switcher trigger with locale switcher shape (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Aligns the theme-switcher trigger on the same chip shape as the locale-switcher. Both controls live side-by-side in the footer's device-prefs cluster (#162); the visual mismatch (round icon button vs. chip) made them read as two unrelated widgets rather than one family. ``` Before: [☀] (round icon button, 44×44) After: [☀ System ▾] (chip — icon + label + chevron) ``` The leading icon stays driven by `currentIcon()` so a **sun / moon / monitor** glyph still flips with the selected mode — that's the visual feedback the original icon-only design existed for, and it's preserved. ## What lands ### [`theme-switcher.html`](apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html) The round Tailwind-utility icon button becomes a `.theme-switcher__trigger` chip with three children — current-mode icon (size 14), localised mode label, `chevron-down` (size 12). Same children layout as `.locale-switcher__trigger`. ### [`theme-switcher.scss`](apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss) Adds `.theme-switcher__trigger` mirroring `.locale-switcher__trigger` rule-for-rule: same chip metrics (min-height 2.75rem for the AAA 44×44 tap target via vertical-padding overflow inside the thin footer), same hover/focus tokens, same dark-mode swap. Two copies rather than a shared `_chip-trigger.scss` partial — two switchers in one app is below the "three similar things" threshold CLAUDE.md sets for extraction. Promotes when a third switcher lands. ## Notes for the reviewer - **No spec changes**. The existing assertions check `button[aria-haspopup="menu"]` + the aria-label content (`"Theme: (open menu)"`). Both preserved — the trigger button still has `aria-haspopup="menu"` from `cdkMenuTriggerFor`, and `triggerAriaLabel()` is unchanged. - **No new i18n strings**. The chip text reuses `currentLabel()`, which already returns `Light` / `Dark` / `System` from the existing `theme.mode.{light,dark,system}` translation units (they were used in the dropdown menu items before this PR; now they also drive the trigger label). Prod i18n-strict build passes. - **Why mirror, not refactor into a shared partial?** Two switchers, identical chip shape — extracting now would be premature abstraction per [CLAUDE.md](CLAUDE.md). When a third switcher lands (an accessibility-panel toggle is the most likely candidate per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)'s preferences-panel plan), `_chip-trigger.scss` or a `` component starts to pay off. The two copies today are mechanical and live next door — easy to keep in sync. - **No `portal-admin` impact.** No theme switcher there (admin chrome is brand-primary-600 hardcoded for now); no change needed. ## Test plan - [x] `pnpm nx run-many -t lint test build --projects=portal-shell` — green; **43 specs pass** (unchanged from main). - [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes. - [ ] **Manual visual smoke** — `pnpm nx serve portal-shell`, open the home page in a browser, confirm: - The theme switcher in the bottom-right of the footer is now a chip with the current mode icon + label + chevron, matching the locale chip beside it. - Hovering both switchers shows the same brand-primary hover color (light + dark mode). - Clicking the theme chip still opens the menu with three options (Light / Dark / System), the current option still carries a `✓` check. - Selecting Dark → trigger icon flips to moon + label flips to "Dark" + page goes dark. Switching to System: trigger icon flips to monitor + label flips to "System". - Tabbing across the footer hits accessibility link → locale → theme in that order, focus rings are identical between the two switchers. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/164 --- .../theme-switcher/theme-switcher.html | 6 ++- .../theme-switcher/theme-switcher.scss | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html index c244318..23928cd 100644 --- a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html @@ -1,10 +1,12 @@ diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss index 5e7faf2..ac6115b 100644 --- a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss @@ -3,6 +3,47 @@ // when the labels grow under translation. dark-mode surface mirrors // the rest of the shell (gray-900 background, gray-800 border). +// Trigger mirrors `.locale-switcher__trigger` — same chip shape, +// same hover / focus tokens, same AAA-friendly 44×44 tap target +// via vertical-padding overflow inside the thin footer. The two +// switchers sit side-by-side in the footer's device-prefs cluster +// (#162), so a single visual family across the pair reads as one +// component. The leading icon stays driven by `currentIcon()` so a +// sun / moon / monitor glyph still signals which mode is selected. +.theme-switcher__trigger { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 2.75rem; + margin: -0.5rem 0; + padding: 0 0.5rem; + border-radius: 0.25rem; + background: transparent; + border: 0; + color: inherit; + font-size: inherit; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.12s ease-out, + color 0.12s ease-out; + + &:hover { + background-color: #f3f4f6; + color: var(--color-brand-primary-500); + + :where(.dark) & { + background-color: #1f2937; + color: var(--color-brand-primary-300); + } + } + + &:focus-visible { + outline: 2px solid var(--color-brand-primary-500); + outline-offset: 2px; + } +} + .theme-switcher__menu { min-width: 11rem; margin-top: 0.25rem;