From f6ba6cde2c5b338e7beee1830d1e2910805fd149 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Mon, 11 May 2026 02:03:20 +0200 Subject: [PATCH] feat(portal-shell): light / dark / auto theme switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- apps/portal-shell/public/logos/apf-small.png | Bin 0 -> 7563 bytes apps/portal-shell/src/app/app.scss | 6 + .../src/app/components/header/header.html | 21 ++-- .../src/app/components/header/header.scss | 4 + .../src/app/components/header/header.spec.ts | 6 + .../src/app/components/header/header.ts | 3 +- .../src/app/components/icon/icon.ts | 10 ++ .../src/app/components/sidebar/sidebar.html | 16 ++- .../src/app/components/sidebar/sidebar.scss | 22 ++++ .../theme-switcher/theme-switcher.html | 30 +++++ .../theme-switcher/theme-switcher.scss | 72 ++++++++++++ .../theme-switcher/theme-switcher.spec.ts | 37 ++++++ .../theme-switcher/theme-switcher.ts | 55 +++++++++ .../pages/accessibility/accessibility.html | 20 +++- .../portal-shell/src/app/pages/home/home.html | 34 +++--- .../app/state/layout-state.service.spec.ts | 103 ++++++++++++---- .../src/app/state/layout-state.service.ts | 111 +++++++++++++++--- apps/portal-shell/src/styles.css | 10 ++ package.json | 1 + pnpm-lock.yaml | 20 ++++ 20 files changed, 505 insertions(+), 76 deletions(-) create mode 100644 apps/portal-shell/public/logos/apf-small.png create mode 100644 apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html create mode 100644 apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss create mode 100644 apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts create mode 100644 apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts diff --git a/apps/portal-shell/public/logos/apf-small.png b/apps/portal-shell/public/logos/apf-small.png new file mode 100644 index 0000000000000000000000000000000000000000..8a5b5644e0f498eb2905cc7c197d53fee23233a2 GIT binary patch literal 7563 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 literal 0 HcmV?d00001 diff --git a/apps/portal-shell/src/app/app.scss b/apps/portal-shell/src/app/app.scss index d8810e0..07a87a9 100644 --- a/apps/portal-shell/src/app/app.scss +++ b/apps/portal-shell/src/app/app.scss @@ -21,6 +21,12 @@ min-width: 0; overflow-y: auto; background-color: #f9fafb; + color: #111827; + + :where(.dark) & { + background-color: #0b1220; + color: #e5e7eb; + } } // Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index 8d6b684..aaba46d 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -1,12 +1,14 @@ -
+
@@ -38,25 +40,26 @@ -
+
@if (!collapsed()) { -

- Role: Anonymous +

+ Role: + Anonymous

} diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.scss b/apps/portal-shell/src/app/components/sidebar/sidebar.scss index 4f8dece..2460f6d 100644 --- a/apps/portal-shell/src/app/components/sidebar/sidebar.scss +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.scss @@ -44,14 +44,27 @@ background-color 0.12s ease-out, color 0.12s ease-out; + :where(.dark) & { + color: #e5e7eb; + } + &: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; + + :where(.dark) & { + outline-color: var(--color-brand-primary-300); + } } } @@ -63,6 +76,15 @@ background-color: var(--color-brand-primary-600); color: #fff; } + + :where(.dark) & { + background-color: var(--color-brand-primary-400); + + &:hover { + background-color: var(--color-brand-primary-300); + color: #0b1620; + } + } } .sidebar__label { 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 new file mode 100644 index 0000000..e49de55 --- /dev/null +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html @@ -0,0 +1,30 @@ + + + + + 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 new file mode 100644 index 0000000..5e7faf2 --- /dev/null +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss @@ -0,0 +1,72 @@ +// CDK Menu ships no opinions on visual styling — we colour, frame and +// align it here. The width pin keeps the three items aligned even +// when the labels grow under translation. dark-mode surface mirrors +// the rest of the shell (gray-900 background, gray-800 border). + +.theme-switcher__menu { + min-width: 11rem; + margin-top: 0.25rem; + padding: 0.25rem; + background-color: #fff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -2px rgba(0, 0, 0, 0.05); + + :where(.dark) & { + background-color: #111827; + border-color: #1f2937; + } +} + +.theme-switcher__item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-height: 2.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + background: transparent; + border: 0; + color: #1f2937; + font-size: 0.875rem; + text-align: left; + cursor: pointer; + transition: + background-color 0.12s ease-out, + color 0.12s ease-out; + + :where(.dark) & { + color: #f3f4f6; + } + + &: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__label { + flex: 1 1 auto; +} + +.theme-switcher__item--active { + font-weight: 600; + color: var(--color-brand-primary-500); + + :where(.dark) & { + color: var(--color-brand-primary-300); + } +} diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts new file mode 100644 index 0000000..033170c --- /dev/null +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts @@ -0,0 +1,37 @@ +import { TestBed } from '@angular/core/testing'; +import { LayoutStateService } from '../../state/layout-state.service'; +import { ThemeSwitcher } from './theme-switcher'; + +describe('ThemeSwitcher', () => { + beforeEach(async () => { + localStorage.clear(); + document.documentElement.classList.remove('dark'); + await TestBed.configureTestingModule({ imports: [ThemeSwitcher] }).compileComponents(); + }); + + it('renders a trigger button announcing the current mode', async () => { + const fixture = TestBed.createComponent(ThemeSwitcher); + await fixture.whenStable(); + const button = (fixture.nativeElement as HTMLElement).querySelector( + 'button[aria-haspopup="menu"]', + ) as HTMLButtonElement | null; + expect(button).not.toBeNull(); + expect(button?.getAttribute('aria-label')).toContain('System'); + }); + + it('updates the trigger label and effective theme when a mode is selected', async () => { + const layout = TestBed.inject(LayoutStateService); + const fixture = TestBed.createComponent(ThemeSwitcher); + fixture.detectChanges(); + await fixture.whenStable(); + + layout.setThemeMode('dark'); + fixture.detectChanges(); + await fixture.whenStable(); + const button = (fixture.nativeElement as HTMLElement).querySelector( + 'button[aria-haspopup="menu"]', + ) as HTMLButtonElement | null; + expect(button?.getAttribute('aria-label')).toContain('Dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); +}); diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts new file mode 100644 index 0000000..f4d1ae0 --- /dev/null +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts @@ -0,0 +1,55 @@ +import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { Icon, type IconName } from '../icon/icon'; +import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service'; + +/** + * Light / dark / auto color-scheme switcher rendered in the header. + * + * The trigger is a single icon button whose glyph reflects the + * currently SELECTED mode (sun / moon / monitor — not the effective + * theme), so the user can tell which mode they're in even when `auto` + * happens to resolve to the same scheme as a manual pick. Clicking + * the button opens a CDK-driven menu (proper roving tabindex, + * Escape/click-outside dismissal) with the three options; the + * currently-selected one gets a check mark. + * + * State lives in `LayoutStateService` — this component is a thin view + * over `themeMode()` + `setThemeMode()`. + */ + +interface ModeOption { + readonly mode: ThemeMode; + readonly label: string; + readonly icon: IconName; +} + +const MODE_OPTIONS: readonly ModeOption[] = [ + { mode: 'light', label: 'Light', icon: 'sun' }, + { mode: 'dark', label: 'Dark', icon: 'moon' }, + { mode: 'auto', label: 'System', icon: 'monitor' }, +]; + +@Component({ + selector: 'app-theme-switcher', + imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, Icon], + templateUrl: './theme-switcher.html', + styleUrl: './theme-switcher.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ThemeSwitcher { + private readonly layout = inject(LayoutStateService); + + protected readonly options = MODE_OPTIONS; + protected readonly mode = this.layout.themeMode; + protected readonly currentIcon = computed( + () => MODE_OPTIONS.find((o) => o.mode === this.mode())?.icon ?? 'monitor', + ); + protected readonly currentLabel = computed( + () => MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ?? 'System', + ); + + protected select(mode: ThemeMode): void { + this.layout.setThemeMode(mode); + } +} diff --git a/apps/portal-shell/src/app/pages/accessibility/accessibility.html b/apps/portal-shell/src/app/pages/accessibility/accessibility.html index fcdad37..765c498 100644 --- a/apps/portal-shell/src/app/pages/accessibility/accessibility.html +++ b/apps/portal-shell/src/app/pages/accessibility/accessibility.html @@ -1,18 +1,26 @@
-

{{ copy().pageTitle }}

+

+ {{ copy().pageTitle }} +

-

{{ copy().introTitle }}

-

{{ copy().introBody }}

+

+ {{ copy().introTitle }} +

+

+ {{ copy().introBody }} +

-

+

{{ copy().panelNoticeTitle }}

-

{{ copy().panelNoticeBody }}

+

+ {{ copy().panelNoticeBody }} +

diff --git a/apps/portal-shell/src/app/pages/home/home.html b/apps/portal-shell/src/app/pages/home/home.html index 5cac0a2..bc06bfd 100644 --- a/apps/portal-shell/src/app/pages/home/home.html +++ b/apps/portal-shell/src/app/pages/home/home.html @@ -1,30 +1,36 @@
-

Welcome to APF Portal

-

+

+ Welcome to APF Portal +

+

This is the placeholder home page for the APF Portal. Real content lands once the comms team and the accessibility review panel sign off on the copy.

-

System status

+

+ System status +

@if (isLoading()) { -

Checking the backend…

+

+ Checking the backend… +

} @else if (health(); as h) {
-
Service
-
{{ h.service }}
-
Version
-
{{ h.version }}
-
Uptime
-
{{ h.uptimeSeconds }} s
-
Status
-
{{ h.status }}
+
Service
+
{{ h.service }}
+
Version
+
{{ h.version }}
+
Uptime
+
{{ h.uptimeSeconds }} s
+
Status
+
{{ h.status }}
} @else if (isError()) { -

+

Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured.

} diff --git a/apps/portal-shell/src/app/state/layout-state.service.spec.ts b/apps/portal-shell/src/app/state/layout-state.service.spec.ts index 767e19a..c031795 100644 --- a/apps/portal-shell/src/app/state/layout-state.service.spec.ts +++ b/apps/portal-shell/src/app/state/layout-state.service.spec.ts @@ -4,40 +4,91 @@ import { LayoutStateService } from './layout-state.service'; describe('LayoutStateService', () => { beforeEach(() => { localStorage.clear(); + document.documentElement.classList.remove('dark'); TestBed.resetTestingModule(); }); - it('defaults to expanded when no preference is persisted', () => { - const service = TestBed.inject(LayoutStateService); - expect(service.sidebarCollapsed()).toBe(false); + describe('sidebar state', () => { + it('defaults to expanded when no preference is persisted', () => { + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(false); + }); + + it('initialises from the persisted preference', () => { + localStorage.setItem('portal-shell:sidebar-collapsed', '1'); + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(true); + }); + + it('toggleSidebar flips the value and persists it', () => { + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(false); + + service.toggleSidebar(); + expect(service.sidebarCollapsed()).toBe(true); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + + service.toggleSidebar(); + expect(service.sidebarCollapsed()).toBe(false); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0'); + }); + + it('setSidebarCollapsed writes the explicit value', () => { + const service = TestBed.inject(LayoutStateService); + service.setSidebarCollapsed(true); + expect(service.sidebarCollapsed()).toBe(true); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + }); }); - it('initialises from the persisted preference', () => { - localStorage.setItem('portal-shell:sidebar-collapsed', '1'); - const service = TestBed.inject(LayoutStateService); - expect(service.sidebarCollapsed()).toBe(true); - }); + describe('theme state', () => { + it('defaults to auto when no preference is persisted', () => { + const service = TestBed.inject(LayoutStateService); + expect(service.themeMode()).toBe('auto'); + }); - it('toggleSidebar flips the value and persists it', () => { - const service = TestBed.inject(LayoutStateService); - expect(service.sidebarCollapsed()).toBe(false); + it('initialises from the persisted theme mode', () => { + localStorage.setItem('portal-shell:theme-mode', 'dark'); + const service = TestBed.inject(LayoutStateService); + expect(service.themeMode()).toBe('dark'); + }); - service.toggleSidebar(); - expect(service.sidebarCollapsed()).toBe(true); - TestBed.tick(); - expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + it('ignores an invalid persisted value and falls back to auto', () => { + localStorage.setItem('portal-shell:theme-mode', 'sepia' as unknown as string); + const service = TestBed.inject(LayoutStateService); + expect(service.themeMode()).toBe('auto'); + }); - service.toggleSidebar(); - expect(service.sidebarCollapsed()).toBe(false); - TestBed.tick(); - expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0'); - }); + it('setThemeMode persists the choice and toggles the .dark class on ', () => { + const service = TestBed.inject(LayoutStateService); + const html = document.documentElement; - it('setSidebarCollapsed writes the explicit value', () => { - const service = TestBed.inject(LayoutStateService); - service.setSidebarCollapsed(true); - expect(service.sidebarCollapsed()).toBe(true); - TestBed.tick(); - expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + service.setThemeMode('dark'); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:theme-mode')).toBe('dark'); + expect(html.classList.contains('dark')).toBe(true); + expect(service.effectiveTheme()).toBe('dark'); + + service.setThemeMode('light'); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:theme-mode')).toBe('light'); + expect(html.classList.contains('dark')).toBe(false); + expect(service.effectiveTheme()).toBe('light'); + }); + + it('resolves auto via the system preference (jsdom defaults to light)', () => { + const service = TestBed.inject(LayoutStateService); + service.setThemeMode('auto'); + TestBed.tick(); + expect(service.themeMode()).toBe('auto'); + // jsdom's matchMedia reports no `prefers-color-scheme: dark` match, + // so 'auto' resolves to light. We assert the resolution path — + // not the literal value of the system preference. + expect(service.effectiveTheme()).toBe('light'); + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); }); }); diff --git a/apps/portal-shell/src/app/state/layout-state.service.ts b/apps/portal-shell/src/app/state/layout-state.service.ts index 549c77f..5374377 100644 --- a/apps/portal-shell/src/app/state/layout-state.service.ts +++ b/apps/portal-shell/src/app/state/layout-state.service.ts @@ -1,38 +1,84 @@ -import { Injectable, effect, signal } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { Injectable, computed, effect, inject, signal } from '@angular/core'; /** * Shell-level layout state shared by the header and the sidebar. * - * Currently a single piece of state: whether the sidebar is collapsed - * to its icon-only rail. The collapsed state is persisted in - * `localStorage` so it survives reloads without a backend round-trip. + * v1 surfaces two settings: + * - `sidebarCollapsed`: whether the sidebar is collapsed to its + * icon-only rail. The header reads it to size its logo zone in + * lock-step with the sidebar. + * - `themeMode`: the user's color-scheme preference among + * `light` / `dark` / `auto`. `effectiveTheme` resolves it to a + * concrete `'light' | 'dark'` by consulting the system preference + * when in `auto` mode, and reacts to OS-level theme changes. + * A side-effect toggles the `.dark` class on so every + * `dark:` Tailwind utility flips at once. * - * Lives outside of `` so that `` can also - * read it — that's what lets the header's logo zone size itself to - * match the sidebar width, and stay perfectly aligned with the rail - * below it. As more cross-cutting shell state lands (density, theme, - * panel pinning, …), it joins here rather than spreading across - * sibling components. + * Both pieces of state persist in `localStorage` so the user's + * preferences survive a reload without a backend round-trip. + * + * Lives outside of the components that consume it so multiple + * surfaces stay synchronised without prop-drilling, and so the + * service is the natural home for future cross-cutting shell state + * (density, RTL, panel pinning, ...). */ -const STORAGE_KEY = 'portal-shell:sidebar-collapsed'; +const SIDEBAR_STORAGE_KEY = 'portal-shell:sidebar-collapsed'; +const THEME_STORAGE_KEY = 'portal-shell:theme-mode'; +const DARK_CLASS = 'dark'; + +export type ThemeMode = 'light' | 'dark' | 'auto'; @Injectable({ providedIn: 'root' }) export class LayoutStateService { - private readonly _sidebarCollapsed = signal(readPersistedCollapsed()); + private readonly document = inject(DOCUMENT); + + private readonly _sidebarCollapsed = signal(readPersistedSidebarCollapsed()); + private readonly _themeMode = signal(readPersistedThemeMode()); + private readonly _systemPrefersDark = signal(systemPrefersDark(this.document)); readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly(); + readonly themeMode = this._themeMode.asReadonly(); + readonly effectiveTheme = computed<'light' | 'dark'>(() => { + const mode = this._themeMode(); + if (mode === 'auto') { + return this._systemPrefersDark() ? 'dark' : 'light'; + } + return mode; + }); constructor() { effect(() => { const value = this._sidebarCollapsed(); try { - localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); + localStorage.setItem(SIDEBAR_STORAGE_KEY, value ? '1' : '0'); } catch { // localStorage can throw in private-mode contexts; the UI // still works, the preference just doesn't persist. } }); + + effect(() => { + const mode = this._themeMode(); + try { + localStorage.setItem(THEME_STORAGE_KEY, mode); + } catch { + // See above. + } + }); + + effect(() => { + const theme = this.effectiveTheme(); + const root = this.document.documentElement; + if (theme === 'dark') { + root.classList.add(DARK_CLASS); + } else { + root.classList.remove(DARK_CLASS); + } + }); + + this.watchSystemPreference(); } toggleSidebar(): void { @@ -42,12 +88,47 @@ export class LayoutStateService { setSidebarCollapsed(value: boolean): void { this._sidebarCollapsed.set(value); } + + setThemeMode(mode: ThemeMode): void { + this._themeMode.set(mode); + } + + private watchSystemPreference(): void { + const mql = this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)'); + if (!mql) { + return; + } + const listener = (e: MediaQueryListEvent) => this._systemPrefersDark.set(e.matches); + // `addEventListener` is the modern API; older Safari only exposes + // the deprecated `addListener` — guarded for robustness. + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', listener); + } else { + mql.addListener(listener); + } + } } -function readPersistedCollapsed(): boolean { +function readPersistedSidebarCollapsed(): boolean { try { - return localStorage.getItem(STORAGE_KEY) === '1'; + return localStorage.getItem(SIDEBAR_STORAGE_KEY) === '1'; } catch { return false; } } + +function readPersistedThemeMode(): ThemeMode { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY); + if (raw === 'light' || raw === 'dark' || raw === 'auto') { + return raw; + } + } catch { + // fall through + } + return 'auto'; +} + +function systemPrefersDark(doc: Document): boolean { + return doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; +} diff --git a/apps/portal-shell/src/styles.css b/apps/portal-shell/src/styles.css index 2b1d81a..f94bca5 100644 --- a/apps/portal-shell/src/styles.css +++ b/apps/portal-shell/src/styles.css @@ -9,6 +9,16 @@ @import 'tailwindcss'; +/* + * Class-based dark mode. Tailwind v4 default is media-query-driven + * (`prefers-color-scheme`); we override it so a `.dark` class on + * (set by `LayoutStateService` from the user's explicit + * preference, or computed from the system preference in `auto` mode) + * is the single switch. `&:where(.dark, .dark *)` keeps specificity + * neutral so utility ordering doesn't shift. + */ +@custom-variant dark (&:where(.dark, .dark *)); + /* * Brand palette — APF charte graphique. * diff --git a/package.json b/package.json index fb70ef3..c91f1db 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "webpack-cli": "^5.1.4" }, "dependencies": { + "@angular/cdk": "~21.2.10", "@angular/common": "~21.2.0", "@angular/compiler": "~21.2.0", "@angular/core": "~21.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7830029..787996c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: .: dependencies: + '@angular/cdk': + specifier: ~21.2.10 + version: 21.2.10(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/common': specifier: ~21.2.0 version: 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) @@ -575,6 +578,14 @@ packages: vitest: optional: true + '@angular/cdk@21.2.10': + resolution: {integrity: sha512-yfCzUFNfeSMNnCkc0P5Pozqz1EViDe9KLPQyHVY/hApsNgBWvckpA+ZEWgKNfAf72f8bUJvZoHejaSMGYrpvuw==} + peerDependencies: + '@angular/common': ^21.0.0 || ^22.0.0 + '@angular/core': ^21.0.0 || ^22.0.0 + '@angular/platform-browser': ^21.0.0 || ^22.0.0 + rxjs: ^6.5.3 || ^7.4.0 + '@angular/cli@21.2.10': resolution: {integrity: sha512-ezf9LM0GgexG2l8ae/uN4fUyxGqeFEH9iu30mUMU5dwow76aK+b4Abuf5eSuR0F8zTLEA3ZUEYywI+gajbAUuA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -10383,6 +10394,15 @@ snapshots: - tsx - yaml + '@angular/cdk@21.2.10(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)) + parse5: 8.0.1 + rxjs: 7.8.2 + tslib: 2.8.1 + '@angular/cli@21.2.10(@types/node@24.12.3)(chokidar@5.0.0)': dependencies: '@angular-devkit/architect': 0.2102.10(chokidar@5.0.0)