feat(portal-shell): light / dark / auto theme switcher (#86)
CI / check (push) Successful in 3m38s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m53s
CI / a11y (push) Successful in 1m27s
CI / perf (push) Successful in 2m28s

## 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.
- [ ] 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.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #86
This commit was merged in pull request #86.
This commit is contained in:
2026-05-11 03:01:11 +02:00
parent b6aa17f6a0
commit 0ae7e0e23d
20 changed files with 505 additions and 76 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+6
View File
@@ -21,6 +21,12 @@
min-width: 0; min-width: 0;
overflow-y: auto; overflow-y: auto;
background-color: #f9fafb; background-color: #f9fafb;
color: #111827;
:where(.dark) & {
background-color: #0b1220;
color: #e5e7eb;
}
} }
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully // Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
@@ -1,12 +1,14 @@
<header class="flex h-16 items-center border-b border-gray-200 bg-white"> <header
class="flex h-16 items-center border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
>
<div class="header__logo-zone" [class.header__logo-zone--collapsed]="collapsed()"> <div class="header__logo-zone" [class.header__logo-zone--collapsed]="collapsed()">
<a <a
routerLink="/" routerLink="/"
class="flex h-full items-center gap-3 rounded-md text-brand-primary-500 transition-colors hover:text-brand-primary-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500" class="flex h-full items-center gap-3 rounded-md text-brand-primary-500 transition-colors hover:text-brand-primary-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:text-brand-primary-300 dark:hover:text-brand-primary-200"
[class.justify-center]="collapsed()" [class.justify-center]="collapsed()"
> >
<img <img
src="logos/apf-portal.svg" src="logos/apf-small.png"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width="36" width="36"
@@ -23,14 +25,14 @@
<form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()"> <form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()">
<label class="sr-only" for="global-search">Search the portal</label> <label class="sr-only" for="global-search">Search the portal</label>
<div <div
class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200" class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700"
> >
<app-icon name="search" [size]="18" /> <app-icon name="search" [size]="18" />
<input <input
id="global-search" id="global-search"
type="search" type="search"
placeholder="Search…" placeholder="Search…"
class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none" class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500"
/> />
</div> </div>
</form> </form>
@@ -38,25 +40,26 @@
<nav aria-label="Primary" class="ml-auto flex items-center gap-1"> <nav aria-label="Primary" class="ml-auto flex items-center gap-1">
<button <button
type="button" type="button"
class="relative inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500" class="relative inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
aria-label="Notifications" aria-label="Notifications"
> >
<app-icon name="bell" /> <app-icon name="bell" />
<span <span
aria-hidden="true" aria-hidden="true"
class="absolute right-2 top-2 h-2 w-2 rounded-full bg-brand-accent-400 ring-2 ring-white" class="absolute right-2 top-2 h-2 w-2 rounded-full bg-brand-accent-400 ring-2 ring-white dark:ring-gray-900"
></span> ></span>
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500" class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
aria-label="Help" aria-label="Help"
> >
<app-icon name="circle-help" /> <app-icon name="circle-help" />
</button> </button>
<app-theme-switcher />
<button <button
type="button" type="button"
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500" class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
aria-label="Settings" aria-label="Settings"
> >
<app-icon name="settings" /> <app-icon name="settings" />
@@ -15,6 +15,10 @@
border-right: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;
transition: width 0.18s ease-out; transition: width 0.18s ease-out;
:where(.dark) & {
border-right-color: #1f2937;
}
&--collapsed { &--collapsed {
width: 4rem; width: 4rem;
padding: 0 0.5rem; padding: 0 0.5rem;
@@ -48,6 +48,12 @@ describe('Header', () => {
expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull(); expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull();
}); });
it('embeds the theme switcher', async () => {
const fixture = TestBed.createComponent(Header);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
});
it('renders the logo zone, expanded with wordmark by default', async () => { it('renders the logo zone, expanded with wordmark by default', async () => {
const fixture = TestBed.createComponent(Header); const fixture = TestBed.createComponent(Header);
fixture.detectChanges(); fixture.detectChanges();
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { Icon } from '../icon/icon'; import { Icon } from '../icon/icon';
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
import { LayoutStateService } from '../../state/layout-state.service'; import { LayoutStateService } from '../../state/layout-state.service';
/** /**
@@ -23,7 +24,7 @@ import { LayoutStateService } from '../../state/layout-state.service';
*/ */
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
imports: [RouterLink, Icon], imports: [RouterLink, Icon, ThemeSwitcher],
templateUrl: './header.html', templateUrl: './header.html',
styleUrl: './header.scss', styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -4,6 +4,8 @@ import {
Bell, Bell,
Building2, Building2,
Calendar, Calendar,
Check,
ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
CircleHelp, CircleHelp,
@@ -15,9 +17,12 @@ import {
type LucideIconData, type LucideIconData,
LucideAngularModule, LucideAngularModule,
Mail, Mail,
Monitor,
Moon,
Search, Search,
Settings, Settings,
Store, Store,
Sun,
UsersRound, UsersRound,
Wrench, Wrench,
} from 'lucide-angular'; } from 'lucide-angular';
@@ -48,6 +53,8 @@ const ICON_REGISTRY = {
bell: Bell, bell: Bell,
'building-2': Building2, 'building-2': Building2,
calendar: Calendar, calendar: Calendar,
check: Check,
'chevron-down': ChevronDown,
'chevron-left': ChevronLeft, 'chevron-left': ChevronLeft,
'chevron-right': ChevronRight, 'chevron-right': ChevronRight,
'circle-help': CircleHelp, 'circle-help': CircleHelp,
@@ -57,9 +64,12 @@ const ICON_REGISTRY = {
home: House, home: House,
'layout-dashboard': LayoutDashboard, 'layout-dashboard': LayoutDashboard,
mail: Mail, mail: Mail,
monitor: Monitor,
moon: Moon,
search: Search, search: Search,
settings: Settings, settings: Settings,
store: Store, store: Store,
sun: Sun,
'users-round': UsersRound, 'users-round': UsersRound,
wrench: Wrench, wrench: Wrench,
} satisfies Record<string, LucideIconData>; } satisfies Record<string, LucideIconData>;
@@ -1,5 +1,5 @@
<aside <aside
class="sidebar flex h-full flex-col border-r border-gray-200 bg-white" class="sidebar flex h-full flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
[class.sidebar--collapsed]="collapsed()" [class.sidebar--collapsed]="collapsed()"
[attr.aria-label]="'Sidebar navigation'" [attr.aria-label]="'Sidebar navigation'"
> >
@@ -7,7 +7,9 @@
@for (group of menu; track group.label) { @for (group of menu; track group.label) {
<div class="mb-4"> <div class="mb-4">
@if (!collapsed()) { @if (!collapsed()) {
<h2 class="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-500"> <h2
class="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
{{ group.label }} {{ group.label }}
</h2> </h2>
} }
@@ -49,7 +51,7 @@
} }
</nav> </nav>
<div class="border-t border-gray-200 px-2 py-3"> <div class="border-t border-gray-200 px-2 py-3 dark:border-gray-800">
<nav aria-label="Accessibility" class="mb-3"> <nav aria-label="Accessibility" class="mb-3">
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
<li> <li>
@@ -86,8 +88,12 @@
</nav> </nav>
@if (!collapsed()) { @if (!collapsed()) {
<p class="mb-2 px-3 text-xs text-gray-500" aria-label="Current role: anonymous (signed-out)"> <p
Role: <span class="font-medium text-gray-700">Anonymous</span> class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
aria-label="Current role: anonymous (signed-out)"
>
Role:
<span class="font-medium text-gray-700 dark:text-gray-200">Anonymous</span>
</p> </p>
} }
@@ -44,14 +44,27 @@
background-color 0.12s ease-out, background-color 0.12s ease-out,
color 0.12s ease-out; color 0.12s ease-out;
:where(.dark) & {
color: #e5e7eb;
}
&:hover { &:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
color: var(--color-brand-primary-500); color: var(--color-brand-primary-500);
:where(.dark) & {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
} }
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-brand-primary-500); outline: 2px solid var(--color-brand-primary-500);
outline-offset: 2px; outline-offset: 2px;
:where(.dark) & {
outline-color: var(--color-brand-primary-300);
}
} }
} }
@@ -63,6 +76,15 @@
background-color: var(--color-brand-primary-600); background-color: var(--color-brand-primary-600);
color: #fff; color: #fff;
} }
:where(.dark) & {
background-color: var(--color-brand-primary-400);
&:hover {
background-color: var(--color-brand-primary-300);
color: #0b1620;
}
}
} }
.sidebar__label { .sidebar__label {
@@ -0,0 +1,30 @@
<button
type="button"
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
[cdkMenuTriggerFor]="themeMenu"
[attr.aria-label]="'Theme: ' + currentLabel() + ' (open menu)'"
>
<app-icon [name]="currentIcon()" />
</button>
<ng-template #themeMenu>
<div cdkMenu class="theme-switcher__menu" role="menu" aria-label="Theme">
@for (option of options; track option.mode) {
<button
type="button"
cdkMenuItem
class="theme-switcher__item"
[class.theme-switcher__item--active]="mode() === option.mode"
[attr.aria-checked]="mode() === option.mode"
role="menuitemradio"
(click)="select(option.mode)"
>
<app-icon [name]="option.icon" />
<span class="theme-switcher__label">{{ option.label }}</span>
@if (mode() === option.mode) {
<app-icon name="check" [size]="16" />
}
</button>
}
</div>
</ng-template>
@@ -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);
}
}
@@ -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);
});
});
@@ -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<IconName>(
() => 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);
}
}
@@ -1,18 +1,26 @@
<article class="mx-auto max-w-3xl px-6 py-12" [attr.lang]="copy().htmlLang"> <article class="mx-auto max-w-3xl px-6 py-12" [attr.lang]="copy().htmlLang">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900">{{ copy().pageTitle }}</h1> <h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
{{ copy().pageTitle }}
</h1>
<section class="mt-8" aria-labelledby="intro-heading"> <section class="mt-8" aria-labelledby="intro-heading">
<h2 id="intro-heading" class="text-lg font-medium text-gray-900">{{ copy().introTitle }}</h2> <h2 id="intro-heading" class="text-lg font-medium text-gray-900 dark:text-gray-100">
<p class="mt-3 text-base leading-relaxed text-gray-700">{{ copy().introBody }}</p> {{ copy().introTitle }}
</h2>
<p class="mt-3 text-base leading-relaxed text-gray-700 dark:text-gray-300">
{{ copy().introBody }}
</p>
</section> </section>
<section <section
class="mt-8 rounded-md border-l-4 border-amber-400 bg-amber-50 p-4" class="mt-8 rounded-md border-l-4 border-amber-400 bg-amber-50 p-4 dark:border-amber-500 dark:bg-amber-950/40"
aria-labelledby="status-heading" aria-labelledby="status-heading"
> >
<h2 id="status-heading" class="text-base font-medium text-amber-900"> <h2 id="status-heading" class="text-base font-medium text-amber-900 dark:text-amber-200">
{{ copy().panelNoticeTitle }} {{ copy().panelNoticeTitle }}
</h2> </h2>
<p class="mt-2 text-sm leading-relaxed text-amber-900">{{ copy().panelNoticeBody }}</p> <p class="mt-2 text-sm leading-relaxed text-amber-900 dark:text-amber-200">
{{ copy().panelNoticeBody }}
</p>
</section> </section>
</article> </article>
+20 -14
View File
@@ -1,30 +1,36 @@
<section class="mx-auto max-w-3xl px-6 py-12"> <section class="mx-auto max-w-3xl px-6 py-12">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900">Welcome to APF Portal</h1> <h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
<p class="mt-4 text-base leading-relaxed text-gray-700"> Welcome to APF Portal
</h1>
<p class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300">
This is the placeholder home page for the APF Portal. Real content lands once the comms team and 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. the accessibility review panel sign off on the copy.
</p> </p>
<section <section
aria-labelledby="system-status-heading" aria-labelledby="system-status-heading"
class="mt-10 rounded-md border border-gray-200 bg-white p-5 shadow-sm" class="mt-10 rounded-md border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
> >
<h2 id="system-status-heading" class="text-base font-medium text-gray-900">System status</h2> <h2 id="system-status-heading" class="text-base font-medium text-gray-900 dark:text-gray-100">
System status
</h2>
@if (isLoading()) { @if (isLoading()) {
<p class="mt-2 text-sm text-gray-600" aria-live="polite">Checking the backend…</p> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400" aria-live="polite">
Checking the backend…
</p>
} @else if (health(); as h) { } @else if (health(); as h) {
<dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm"> <dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">Service</dt> <dt class="text-gray-500 dark:text-gray-400">Service</dt>
<dd class="font-medium text-gray-900">{{ h.service }}</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.service }}</dd>
<dt class="text-gray-500">Version</dt> <dt class="text-gray-500 dark:text-gray-400">Version</dt>
<dd class="font-medium text-gray-900">{{ h.version }}</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.version }}</dd>
<dt class="text-gray-500">Uptime</dt> <dt class="text-gray-500 dark:text-gray-400">Uptime</dt>
<dd class="font-medium text-gray-900">{{ h.uptimeSeconds }} s</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.uptimeSeconds }} s</dd>
<dt class="text-gray-500">Status</dt> <dt class="text-gray-500 dark:text-gray-400">Status</dt>
<dd class="font-medium text-green-700">{{ h.status }}</dd> <dd class="font-medium text-green-700 dark:text-green-400">{{ h.status }}</dd>
</dl> </dl>
} @else if (isError()) { } @else if (isError()) {
<p class="mt-2 text-sm text-amber-700" role="status"> <p class="mt-2 text-sm text-amber-700 dark:text-amber-300" role="status">
Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured. Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured.
</p> </p>
} }
@@ -4,40 +4,91 @@ import { LayoutStateService } from './layout-state.service';
describe('LayoutStateService', () => { describe('LayoutStateService', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
document.documentElement.classList.remove('dark');
TestBed.resetTestingModule(); TestBed.resetTestingModule();
}); });
it('defaults to expanded when no preference is persisted', () => { describe('sidebar state', () => {
const service = TestBed.inject(LayoutStateService); it('defaults to expanded when no preference is persisted', () => {
expect(service.sidebarCollapsed()).toBe(false); 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', () => { describe('theme state', () => {
localStorage.setItem('portal-shell:sidebar-collapsed', '1'); it('defaults to auto when no preference is persisted', () => {
const service = TestBed.inject(LayoutStateService); const service = TestBed.inject(LayoutStateService);
expect(service.sidebarCollapsed()).toBe(true); expect(service.themeMode()).toBe('auto');
}); });
it('toggleSidebar flips the value and persists it', () => { it('initialises from the persisted theme mode', () => {
const service = TestBed.inject(LayoutStateService); localStorage.setItem('portal-shell:theme-mode', 'dark');
expect(service.sidebarCollapsed()).toBe(false); const service = TestBed.inject(LayoutStateService);
expect(service.themeMode()).toBe('dark');
});
service.toggleSidebar(); it('ignores an invalid persisted value and falls back to auto', () => {
expect(service.sidebarCollapsed()).toBe(true); localStorage.setItem('portal-shell:theme-mode', 'sepia' as unknown as string);
TestBed.tick(); const service = TestBed.inject(LayoutStateService);
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); expect(service.themeMode()).toBe('auto');
});
service.toggleSidebar(); it('setThemeMode persists the choice and toggles the .dark class on <html>', () => {
expect(service.sidebarCollapsed()).toBe(false); const service = TestBed.inject(LayoutStateService);
TestBed.tick(); const html = document.documentElement;
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0');
});
it('setSidebarCollapsed writes the explicit value', () => { service.setThemeMode('dark');
const service = TestBed.inject(LayoutStateService); TestBed.tick();
service.setSidebarCollapsed(true); expect(localStorage.getItem('portal-shell:theme-mode')).toBe('dark');
expect(service.sidebarCollapsed()).toBe(true); expect(html.classList.contains('dark')).toBe(true);
TestBed.tick(); expect(service.effectiveTheme()).toBe('dark');
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
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);
});
}); });
}); });
@@ -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. * Shell-level layout state shared by the header and the sidebar.
* *
* Currently a single piece of state: whether the sidebar is collapsed * v1 surfaces two settings:
* to its icon-only rail. The collapsed state is persisted in * - `sidebarCollapsed`: whether the sidebar is collapsed to its
* `localStorage` so it survives reloads without a backend round-trip. * 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 <html> so every
* `dark:` Tailwind utility flips at once.
* *
* Lives outside of `<app-sidebar>` so that `<app-header>` can also * Both pieces of state persist in `localStorage` so the user's
* read it — that's what lets the header's logo zone size itself to * preferences survive a reload without a backend round-trip.
* match the sidebar width, and stay perfectly aligned with the rail *
* below it. As more cross-cutting shell state lands (density, theme, * Lives outside of the components that consume it so multiple
* panel pinning, …), it joins here rather than spreading across * surfaces stay synchronised without prop-drilling, and so the
* sibling components. * 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' }) @Injectable({ providedIn: 'root' })
export class LayoutStateService { export class LayoutStateService {
private readonly _sidebarCollapsed = signal(readPersistedCollapsed()); private readonly document = inject(DOCUMENT);
private readonly _sidebarCollapsed = signal(readPersistedSidebarCollapsed());
private readonly _themeMode = signal<ThemeMode>(readPersistedThemeMode());
private readonly _systemPrefersDark = signal(systemPrefersDark(this.document));
readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly(); 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() { constructor() {
effect(() => { effect(() => {
const value = this._sidebarCollapsed(); const value = this._sidebarCollapsed();
try { try {
localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); localStorage.setItem(SIDEBAR_STORAGE_KEY, value ? '1' : '0');
} catch { } catch {
// localStorage can throw in private-mode contexts; the UI // localStorage can throw in private-mode contexts; the UI
// still works, the preference just doesn't persist. // 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 { toggleSidebar(): void {
@@ -42,12 +88,47 @@ export class LayoutStateService {
setSidebarCollapsed(value: boolean): void { setSidebarCollapsed(value: boolean): void {
this._sidebarCollapsed.set(value); 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 { try {
return localStorage.getItem(STORAGE_KEY) === '1'; return localStorage.getItem(SIDEBAR_STORAGE_KEY) === '1';
} catch { } catch {
return false; 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;
}
+10
View File
@@ -9,6 +9,16 @@
@import 'tailwindcss'; @import 'tailwindcss';
/*
* Class-based dark mode. Tailwind v4 default is media-query-driven
* (`prefers-color-scheme`); we override it so a `.dark` class on
* <html> (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. * Brand palette — APF charte graphique.
* *
+1
View File
@@ -111,6 +111,7 @@
"webpack-cli": "^5.1.4" "webpack-cli": "^5.1.4"
}, },
"dependencies": { "dependencies": {
"@angular/cdk": "~21.2.10",
"@angular/common": "~21.2.0", "@angular/common": "~21.2.0",
"@angular/compiler": "~21.2.0", "@angular/compiler": "~21.2.0",
"@angular/core": "~21.2.0", "@angular/core": "~21.2.0",
+20
View File
@@ -17,6 +17,9 @@ importers:
.: .:
dependencies: 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': '@angular/common':
specifier: ~21.2.0 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) 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: vitest:
optional: true 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': '@angular/cli@21.2.10':
resolution: {integrity: sha512-ezf9LM0GgexG2l8ae/uN4fUyxGqeFEH9iu30mUMU5dwow76aK+b4Abuf5eSuR0F8zTLEA3ZUEYywI+gajbAUuA==} 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'} 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 - tsx
- yaml - 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)': '@angular/cli@21.2.10(@types/node@24.12.3)(chokidar@5.0.0)':
dependencies: dependencies:
'@angular-devkit/architect': 0.2102.10(chokidar@5.0.0) '@angular-devkit/architect': 0.2102.10(chokidar@5.0.0)