Commit Graph

14 Commits

Author SHA1 Message Date
julien ee51efb688 feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
CI / scan (push) Successful in 2m13s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m48s
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 3m46s
## Summary

PR 3 of 3 — final piece of the user-menu / profile / cross-app chantier. Closes the loop with the BFF capabilities endpoint, symmetric cross-app entries in both user menus, and a real role label in place of the hardcoded "Anonymous" widget on the portal-shell sidebar.

| PR | Périmètre |
| --- | --- |
| PR 1  | Shared `UserMenu` dropdown + integration. |
| PR 2  | `/profile` pages on both apps. |
| **PR 3 (this one)** | `GET /api/me/capabilities` + real sidebar role label + cross-app menu entries. |

## What lands

### BFF — `GET /api/me/capabilities`

New [`MeModule`](apps/portal-bff/src/me/me.module.ts) wiring a single endpoint:

```ts
GET /api/me/capabilities → { canAccessAdmin: boolean }
```

Resolved against the user-portal session ([`portal_session`](apps/portal-bff/src/me/me.controller.ts) — the path-routed session middleware in `main.ts` already maps `/api/me/*` to that session). Returns 401 if no session is present, consistent with `/api/auth/me`. 5 specs cover the four state combinations + a regression-fence asserting the curated view never leaks the raw `roles` array.

### ADR-0009 amendment

[`docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md`](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — new **"Curated public view"** section codifies the design stance the user picked when we agreed the staging:

> The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side […]. The SPA derives binary UX hints from a dedicated companion endpoint […]. The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles […] does not widen the SPA-side surface.

The routes table grows a row for `/me/capabilities`.

### Portal-shell — capabilities-driven UI

- **New service** [`CapabilitiesService`](apps/portal-shell/src/app/services/capabilities.service.ts) (app-local, not in `feature-auth` — the admin app has its own roles channel via `/api/admin/auth/me` and would never use this). Signals: `capabilities`, `canAccessAdmin`. Fires `GET /me/capabilities` reactively via an `effect` that watches `auth.currentUser()`. Anonymous sessions short-circuit to the all-false default without a fetch — the BFF would 401 anyway.
- **Header** ([`header.ts`](apps/portal-shell/src/app/components/header/header.ts)) — `userMenuItems` is now a `computed`. Profile + Settings stay unconditional; **"Open Portal Admin"** appears only when `canAccessAdmin()` flips true, with `href = environment.adminAppUrl`. Per ADR-0020 the two SPAs live on distinct origins, so this is a raw cross-origin anchor, not a routerLink.
- **Sidebar** ([`sidebar.ts`](apps/portal-shell/src/app/components/sidebar/sidebar.ts) + [`sidebar.html`](apps/portal-shell/src/app/components/sidebar/sidebar.html)) — the hardcoded `Role: Anonymous` widget is replaced by a derived computed:
  - `Anonymous` when no session.
  - `Administrator` when `canAccessAdmin()` is true.
  - `User` otherwise (signed-in, no admin).

  The aria-label gains a `role` placeholder so screen readers hear the live value.

### Portal-admin — symmetric cross-app entry

- **Header** ([`header.ts`](apps/portal-admin/src/app/components/header/header.ts)) — adds an unconditional `Open Portal Shell` row pointing at `environment.shellAppUrl`. Anyone able to reach portal-admin can reach portal-shell, so no capabilities check needed; admins always benefit from a one-click jump back to the end-user surface.

### Environments

`adminAppUrl` and `shellAppUrl` added to the respective [`environment.ts`](apps/portal-shell/src/environments/environment.ts) files (dev defaults: `:4300` for admin, `:4200` for shell). Per-env siblings (staging / prod) will override the host once they exist, per ADR-0018.

### i18n

| Key | EN source | FR target |
| --- | --- | --- |
| `header.userMenu.openAdmin` | Open Portal Admin | Ouvrir Administration APF Portal |
| `sidebar.role.administrator` | Administrator | Administrateur |
| `sidebar.role.user` | User | Utilisateur |
| `sidebar.role.aria` | reshaped with `{role}` placeholder | reshaped likewise |

Admin-side strings stay in English source per ADR-0020.

## Notes for the reviewer

- **Why `CapabilitiesService` in the app, not in `feature-auth`?** Only `portal-shell` will ever call `/api/me/capabilities` — the admin SPA hits `/api/admin/auth/me` which already returns `roles`. Putting the service in `feature-auth` would publish a tree-shakable `providedIn: 'root'` injectable that ships in both bundles. Keeping it app-local makes the boundary explicit.
- **Why a `computed` for `userMenuItems` rather than mutating an array in an `effect`?** Signals + computed = single source of truth. The shared `UserMenu` re-renders automatically when the items list changes (whenever capabilities flips). Less ceremony than maintaining a `WritableSignal<UserMenuItem[]>`.
- **Why the `flushPendingEffects` test helper?** Zoneless apps rely on the signals scheduler to dispatch `effect()` callbacks via micro-task scheduling. `fixture.detectChanges() + whenStable()` once is not enough: the chain is `meReq.flush()` → `_state.set()` → effect scheduled → effect fires → `http.get()` queued. The helper loops 4× to give the scheduler enough rounds to settle before `expectOne(CAPABILITIES_URL)` looks up the request.
- **Why no test for the dev URL values?** `environment.ts` is config that gets swapped at build time per ADR-0018; the values themselves are environmental. Asserting the dev value in a test would lock in a port (4200/4300) that's separately configured in `project.json`.

## Test plan

- [x] `pnpm nx test portal-bff` — **401 specs pass** (was 396, +5 for `MeController`).
- [x] `pnpm nx test portal-shell` — **40 specs pass** (was 35, +5: 3 sidebar role-label + 2 header admin-link).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 18/18 tasks green, including the i18n-strict `portal-shell:build:production`.
- [ ] Manual smoke (with a `Portal.Admin`-assigned account):
  - Sign in on portal-shell → sidebar reads `Role: Administrator`, user menu lists `Open Portal Admin` at the bottom (right above the Sign-out separator), clicking the link opens `localhost:4300`.
  - Sign in on portal-admin → user menu lists `Open Portal Shell`, clicking opens `localhost:4200`.
  - Sign in on portal-shell with a non-admin account → sidebar reads `Role: User`, `Open Portal Admin` is absent.
  - Sign out → sidebar reads `Role: Anonymous`, the menu collapses to its anonymous-state Sign-in button.

## What's next

Chantier closed. The user-menu shape is now stable; further entries (notifications inbox, theme override, locale switcher inside the menu rather than the footer) plug into the existing `items` API without re-shaping the component.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #151
2026-05-15 16:11:26 +02:00
julien a8ead65ea8 feat(shared-ui): user menu dropdown + integration on portal-shell + portal-admin (#149)
CI / scan (push) Successful in 2m28s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m16s
CI / a11y (push) Successful in 1m43s
CI / perf (push) Successful in 3m58s
## Summary

PR 1 of 3 from the user-menu / profile / cross-app-link chantier per the agreed staging:

| PR | Périmètre |
| --- | --- |
| **PR 1 (this one)** | Shared `UserMenu` dropdown component + integration on `portal-shell` and `portal-admin`; anonymous Sign-in button moves to `rounded-md`. |
| PR 2 | `/profile` pages on both apps. |
| PR 3 | `/api/me/capabilities` endpoint + real role surfacing on the sidebar widget + cross-app links in the menu (both directions). |

Lands the gitea / github-shaped avatar dropdown so admins and end users get the familiar "Signed in as / Profile / Settings / Sign out" pattern. Future entries (cross-app link, role-based visibility) plug into the same `items` input without touching the shared component.

## What lands

### Shared component — [`libs/shared/ui/src/lib/user-menu/`](libs/shared/ui/src/lib/user-menu/)

```html
<lib-user-menu
  [displayName]="state.user.displayName"
  [username]="state.user.username"
  [initials]="initials()"
  [items]="userMenuItems"
  [signedInAsLabel]="…"
  [signOutLabel]="…"
  [triggerAriaLabel]="…"
  (signOut)="signOut()"
/>
```

- Avatar trigger (initials in a rounded square + chevron-down hint), CDK `cdkMenuTriggerFor` opening the panel in an overlay portal. Same primitives `ThemeSwitcher` and `LocaleSwitcher` already use — keyboard nav, focus management and Escape-to-close come for free.
- Panel layout: "Signed in as" small-caps header → `displayName` → `username` → separator → caller-supplied `items` → separator → dedicated Sign out row at the bottom.
- `UserMenuItem` shape supports `routerLink` (intra-app) **and** `href` (cross-app / external) so PR 3's "Open Portal Admin" entry can land without re-shaping the API.
- Disabled items render as `aria-disabled` rows with a right-aligned badge — same Soon-chip pattern the admin sidebar already uses for not-yet-shipped entries.
- Sign out is **not** an item — it's a hardcoded row that emits a `signOut` output. Avoids special-casing item types and keeps the destructive action visually + structurally distinct.
- Avatar background is driven by `--user-menu-avatar-bg` / `--user-menu-avatar-fg` CSS custom properties so each host header can re-skin without forking the component (portal-admin uses translucent white over the brand-primary-600 header; portal-shell keeps the default brand-primary-500).

### Portal-shell integration — [`apps/portal-shell/src/app/components/header/`](apps/portal-shell/src/app/components/header/)

- Authenticated state in [header.html](apps/portal-shell/src/app/components/header/header.html) swaps the inline avatar + display name + Sign-out button for `<lib-user-menu>`.
- Anonymous Sign-in button moves from `rounded-full` → `rounded-md` per the reference image. Loading + error chips follow for visual consistency with the new square-ish avatar.
- 6 new strings in [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf): `header.userMenu.{profile,settings,signedInAs,signOut,trigger.aria}` + `common.badge.soon`.

### Portal-admin integration — [`apps/portal-admin/src/app/components/header/`](apps/portal-admin/src/app/components/header/)

- Same swap, leaner: the admin header keeps its inline `.btn--primary` / `.btn--secondary` for anonymous + error states (no search bar / notification cluster, per ADR-0020), only the authenticated state goes through the menu.
- Overrides `--user-menu-avatar-bg` / `--user-menu-avatar-fg` in [`header.scss`](apps/portal-admin/src/app/components/header/header.scss) under `.auth-widget lib-user-menu` so the avatar reads on the brand-primary-600 background. No FR labels (no admin locale in v1 per ADR-0020).

## Notes for the reviewer

- **Why `Sign out` outside the `items` array?** It's the only destructive action in the panel + always present + emits an event rather than navigates. Keeping it special-cased lets the `items` API stay tight (`{ label, icon?, routerLink? | href?, disabled?, badge? }`) and gives each app a single typed entry point (`signOut` output) rather than a brittle `items[].action === 'sign-out'` discriminator.
- **Why `data-testid="user-menu"` on the component host?** The shared spec already covers panel internals; each app's spec needs a top-level handle to reach the trigger without coupling to the avatar CSS class. Same pattern as the existing `data-testid="sign-in-button"`.
- **Profile entry points at `/profile` on both apps in this PR.** Portal-shell already has a demo `/profile` route, so the link works; portal-admin will 404 until PR 2 lands the actual page. Interim cost is acceptable given PR 2 lands directly behind this.
- **No ADR for the component.** It's a UI primitive in `libs/shared/ui` — same tier as `Icon`. Promotion criteria, dark-mode, a11y, and i18n all follow the existing ADRs already in force (ADR-0004 + 0016 + 0019).

## Test plan

- [x] `pnpm nx test shared-ui` — **8 specs pass** (was 3, +5 for `UserMenu`).
- [x] `pnpm nx test portal-shell` — **35 specs pass** (was 34, +1 for the new "opens menu" assertion).
- [x] `pnpm nx test portal-admin` — **46 specs pass** (was 45, +1 for the new "opens menu" assertion).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-ui,shared-state,feature-auth` — 15/15 tasks green, including the i18n-strict `portal-shell:build:production`.
- [ ] Manual smoke — sign in on portal-shell, confirm the avatar lives at the top right with the dropdown opening on click / Enter / ArrowDown, Profile navigates to `/profile`, Settings appears greyed with a "Soon" badge, Sign out triggers the BFF logout. Repeat on portal-admin (the avatar background should pick the translucent-white override over the brand-primary-600 header).

## What's next

PR 2 picks up directly: builds a real `/profile` page on each app, both reading from `feature-auth`'s `currentUser()` signal.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #149
2026-05-15 15:23:39 +02:00
julien 9a9faf9a31 feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget (#113)
CI / check (push) Successful in 3m3s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m41s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 4m24s
## Summary

First user-visible piece of the auth track. The portal-shell SPA now consumes the BFF auth surface (`/api/auth/me`, `/api/auth/login`, `/api/auth/logout`) and the header reflects sign-in state.

- `libs/feature/auth` ships an `AuthService` that fetches `/auth/me` on first injection, holds a signal-backed `AuthState` (`loading` / `anonymous` / `authenticated` / `error`) and exposes `currentUser` + `isLoading` computed signals plus `login()` / `logout()` / `refresh()` methods.
- The header's right-side widget renders four states: a sign-in button when anonymous, the user avatar (initials, `JD` for "Jane Doe") + display name + sign-out button when authenticated, a loading dot before `/me` resolves, and a "Can't reach the server" chip on non-401 failures.
- `login()` / `logout()` go through an injected `AUTH_NAVIGATOR` token whose default calls `window.location.assign(url)`. Specs override it with `vi.fn()` — no `window.location` mocking required.

## Notable choices

**Auto-bootstrap on construction, not via `provideAppInitializer`.** The service fires `/me` from its constructor (unawaited) so consuming components transition through the explicit `loading` state. Blocking app boot on the round-trip would push the first paint behind the network call — bad for TTFB, especially on slow links. The header handles `loading` as a first-class state.

**Discriminated `AuthState` over flat fields.** A single source of truth (`state()`) with four `kind`s lets templates `switch` and narrow automatically. `currentUser` and `isLoading` are computed conveniences but never out of sync with `state`.

**`AUTH_BFF_BASE_URL` + `AUTH_NAVIGATOR` injection tokens.** Decouples the lib from the host's `environment.ts` shape and keeps tests free of `window.location` redefinition (which jsdom resists across multiple specs in the same file — first redefine works, second throws "Cannot redefine property"). The host wires both in `app.config.ts`.

**Curated public user type.** `CurrentUser` mirrors the BFF's `/me` response (`oid`, `tid`, `username`, `displayName`) — no `amr`, no internal claims. The shape lives in `auth.types.ts` so feature code can import it without depending on Angular HTTP details.

**Distinct `error` state.** A network failure / 5xx surfaces a different UI than "not signed in" — "Can't reach the server" chip vs. sign-in button. Avoids the trap of treating any `/me` failure as "anonymous".

## Out of scope (next PRs)

- Route guards (protecting routes from anonymous users). For now the header is the only consumer.
- Auto-refresh of the session before idle timeout.
- HTTP interceptor that redirects to `/auth/login` on a 401 from any other BFF call.
- Per-locale styling polish on the new header strings.

## Test plan

- [x] `pnpm nx test feature-auth` → **8/8 pass**.
- [x] `pnpm nx test portal-shell` → **32/32 pass** (was 27 before).
- [x] `pnpm nx lint portal-shell feature-auth` → clean.
- [x] `pnpm nx build portal-shell` → main bundle 488 kB raw / 129.94 kB transfer; well under the 300 kB gzip budget from ADR-0017.
- [ ] Manual smoke once the BFF is up:
  - [ ] Anonymous landing → header shows "Sign in".
  - [ ] Click "Sign in" → BFF /login → Entra → callback → SPA lands with avatar + display name in the header.
  - [ ] Click "Sign out" → BFF /logout → Entra logout → back at SPA, header back to "Sign in".

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #113
2026-05-12 20:11:34 +02:00
julien 9443a52bb7 chore(brand): swap header logo to a real svg + ship favicons for portal-admin (#106)
CI / check (push) Successful in 5m23s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m42s
CI / a11y (push) Successful in 3m21s
CI / perf (push) Successful in 6m22s
## Summary

Two related brand-asset cleanups, bundled per request:

### portal-shell — header logo
- Replace [`apf-small.png`](apps/portal-shell/public/logos/) (a 7.6 KB raster we'd extracted from the original PNG-in-SVG and re-encoded through sharp) with [`apf-logo.svg`](apps/portal-shell/public/logos/apf-logo.svg) — an actual 1024×1024 vector export (2.1 KB). Sharper at any density, smaller payload, no rasterisation artefacts when zoomed.
- [`header.html`](apps/portal-shell/src/app/components/header/header.html) swaps `<img src=…>` accordingly.
- The wide-format `apf-portal.svg` stays in place for future surfaces (login splash, etc.).

### portal-admin — favicons + PWA manifest
Mirrors what PR #84 did for `portal-shell`:
- Copy the six favicon images (`favicon.ico`, `favicon.svg`, `favicon-96x96.png`, `apple-touch-icon.png`, `web-app-manifest-{192,512}.png`) into [`apps/portal-admin/public/favicons/`](apps/portal-admin/public/favicons/). Single visual identity across the two apps.
- Customise [`site.webmanifest`](apps/portal-admin/public/favicons/site.webmanifest) for admin: `name: "APF Portal Admin"`, `short_name: "Admin"`. Everything else (icons, theme-color, display) stays identical to portal-shell's manifest.
- Wire the `<link>` block + `<meta name="theme-color">` in [`apps/portal-admin/src/index.html`](apps/portal-admin/src/index.html).
- Remove the obsolete top-level `apps/portal-admin/public/favicon.ico` — now under `favicons/`, served via `<link rel="shortcut icon">`.

## Decision worth flagging

**Assets duplicated rather than shared via a lib.** Both apps ship their own copy of the 7 favicon files (~120 KB binary each). The alternative — a `shared-assets` (or extended `shared-tokens`) lib with the assets glob copied into each app's `dist/` at build time — is the architecturally tidier path, but introduces a build-config change with no real payoff at our scale. Revisit if a third surface (e.g., a future static landing page) ends up needing the same set.

## Verification

- `nx run-many -t lint test build --projects=portal-shell,portal-admin` — green.
- Both `dist/apps/{portal-shell,portal-admin}/browser/{en,fr}/favicons/` ship the seven expected files.
- Admin's emitted `site.webmanifest` carries `"name": "APF Portal Admin"`.
- Admin's emitted `index.html` carries the full `<link>` block + `theme-color` meta.
- Portal-shell ships `apf-logo.svg` in its `logos/` folder per locale; `apf-small.png` is gone.

## Test plan

- [x] Lint + test + build green.
- [x] Built outputs spot-checked (assets ship, manifest text correct, index.html wired).
- [ ] Manual: `nx serve portal-shell` → header shows the new SVG logo crisp at 1x / 2x / 3x DPR.
- [ ] Manual: `nx serve portal-admin` → tab favicon visible, dev tools → Application → Manifest shows "APF Portal Admin" with no errors.
- [ ] Manual: install portal-admin as a PWA from a Chromium browser → the install dialog reads "Install APF Portal Admin", home-screen icon uses the same family as portal-shell.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #106
2026-05-12 11:59:04 +02:00
julien 8329fa133d refactor(libs): graduate Icon / LayoutStateService / brand tokens to libs/shared/* (#99)
CI / check (push) Successful in 3m34s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m16s
CI / a11y (push) Successful in 2m3s
CI / perf (push) Successful in 3m34s
## Summary

Per ADR-0020 §"Shared-libs graduation": the three primitives that `portal-shell` (today) and `portal-admin` (next) will both share move out of `apps/portal-shell/src/` and into the workspace libs. Mechanical move — no behaviour change, prepares the ground for the admin-app PR.

## Graduated

| Primitive | Old location | New location |
|---|---|---|
| `Icon` component (+ `IconName` type) | `apps/portal-shell/src/app/components/icon/` | [`libs/shared/ui/src/lib/icon/`](libs/shared/ui/src/lib/icon/) |
| `LayoutStateService` (+ `ThemeMode` type) | `apps/portal-shell/src/app/state/` | [`libs/shared/state/src/lib/`](libs/shared/state/src/lib/) |
| Brand-palette `@theme` block | `apps/portal-shell/src/styles.css` | [`libs/shared/tokens/src/brand-tokens.css`](libs/shared/tokens/src/brand-tokens.css) |

## Notable changes

- **Icon selector renamed `app-icon` → `lib-icon`.** Required by the lib's ESLint `@angular-eslint/component-selector` rule (prefix `lib` for shared libs). All ~20 usages in `portal-shell` templates updated in one sweep.
- **New `shared-state` library** generated via `nx g @nx/angular:library libs/shared/state`. Mirrors the existing `feature-auth` / `shared-ui` shape (vite + Angular + spec setup). `tsconfig.base.json` path alias `shared-state` added by the generator.
- **Lib tags rebalanced** to satisfy the module-boundary lint rule:
  - `shared-state`: new lib → `scope:shared, type:shared`.
  - `shared-ui`: was `scope:portal-shell` (scaffold default) → `scope:shared, type:shared`.
- **`shared-tokens` is now CSS-only.** The placeholder TS function is gone; `index.ts` is an empty `export {}` with a TODO note. Vitest config flips `passWithNoTests: true` so the test target stops failing on the empty lib.
- **`portal-shell`'s `styles.css`** drops the inline `@theme {}` block and instead `@import`s `../../../libs/shared/tokens/src/brand-tokens.css`. Both apps will read the same source.
- Placeholder scaffolded files in `shared-ui/src/lib/shared-ui/` removed.

## Verification

- `nx run-many -t lint test build` across `portal-shell, shared-ui, shared-state, shared-tokens` — green.
- Tests redistributed: **portal-shell 28**, **shared-state 9**, **shared-ui 3**, **shared-tokens 0** = 40 total (same as before the move).
- Production build: **128.7 KB gzip** initial per locale (was 128.5 KB on `main` — noise-level delta).
- Brand-color CSS variables (`--color-brand-primary-500: #12546c`, …) still inlined in the produced `styles-*.css`.
- `dist/.../browser/{en,fr}/` emitted as expected.

## What this PR explicitly does NOT do

- Move other components (Header, Sidebar, Footer, ThemeSwitcher, LocaleSwitcher). Those are app-specific shell concerns; if `portal-admin` ends up needing them, they graduate in their own PR.
- Set up `portal-admin`. That's the next PR on the admin track — and now imports from `shared-ui` / `shared-state` / `shared-tokens` will Just Work.
- Refactor `feature-auth`'s tagging. It still says `scope:portal-shell`; revisit when the auth implementation actually lands and the dual-audience design (per ADR-0008) makes the right scope obvious.

## Test plan

- [x] Per-project run-many — green.
- [x] Production build emits both locales with brand tokens applied.
- [ ] Manual: `pnpm exec nx serve portal-shell` — the dev experience is unchanged.
- [ ] Manual: serve-static + locale switcher / theme switcher / sidebar collapse / navigation — all the behaviours the moved primitives drive still work in production.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #99
2026-05-12 01:17:30 +02:00
julien 192cc483b6 feat(portal-shell): locale switcher in the footer (#95)
CI / scan (push) Successful in 2m4s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m15s
CI / a11y (push) Successful in 1m47s
CI / perf (push) Successful in 3m40s
## Summary

Add a locale switcher (FR / EN) to the footer's right cluster, next to the accessibility link. Closes the user-facing i18n loop — the FR bundle has existed since the sweep PR but had no in-app entry point until now.

The switcher reads the active locale from `<html lang>` (set per locale by the build), shows the native name + a globe + chevron-down chip, and on selection rewrites the URL prefix (`/en/...` ↔ `/fr/...`) and hard-refreshes so the right bundle boots — per ADR-0019.

## Architecture

Same pattern as the theme switcher:

- **`@angular/cdk/menu`** for the trigger + roving-focus menu + escape / click-outside dismissal.
- **`ViewEncapsulation.None`** because the menu opens in an overlay portal outside the component host — BEM-style class names (`.locale-switcher__*`) keep the global emissions contained.
- Each menu item carries `[attr.lang]="locale.code"` so screen readers pronounce the native names correctly.

## Decisions worth flagging

- **Locale display names ("Français", "English") are NOT i18n-marked.** Universal switcher convention: each language is always shown in its own language. Translating them would defeat the purpose for someone trying to switch *away* from the active locale they can't read.
- **No backend, no cookie, no smart `/` redirect — yet.** The URL prefix is the source of truth in v1: the next visit lands on the same locale because the URL says so. The `__Host-portal_locale` cookie + the BFF route at `/api/preferences/locale` + the smart `/` redirect described in ADR-0019 wait for the auth flow to bring the BFF online.
- **Dev-mode limitation, accepted.** Under `nx serve`, the dev server has no locale prefix in the URL — clicking the trigger lands on a non-existent path. The switcher works against the production build (`nx run portal-shell:serve-static` or any real deploy). This matches ADR-0019: dev = source locale, locale switching is a built-bundle concern.
- **Touch target.** Visible height stays at ~28 px to fit the 40 px footer; vertical padding extends the tap area to **44 px**, meeting the ADR-0016 AAA minimum without inflating the footer.

## Translation choices

Two new i18n keys:

| Key | Source (EN) | Target (FR) |
|---|---|---|
| `@@locale.trigger.aria` | `Language: <name> (change language)` | `Langue : <name> (changer de langue)` |
| `@@locale.menu.aria` | `Language` | `Langue` |

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green. **40 / 40 specs** (+5: four new for `LocaleSwitcher`, one for the footer embedding).
- [x] Production build emits both locales; spot-checked the FR bundle for "Langue", "changer de langue", and the absence of "Language:" leakage.
- [ ] Manual: build prod + serve-static → on `/en/`, click switcher → lands on `/fr/`; widget shows "Français"; reload stays in FR.
- [ ] Manual: keyboard the trigger → ENTER opens, arrows navigate, ENTER selects, ESC closes; focus returns to trigger on close.
- [ ] Manual: screen reader announces both languages with the right pronunciation (`<button lang="fr">Français</button>` is announced with the FR voice).
- [ ] Manual: query/hash preserved across switch (`/en/accessibility?foo=bar` → `/fr/accessibility?foo=bar`).

## What this PR explicitly does NOT do

- BFF route `/api/preferences/locale` + `__Host-portal_locale` cookie.
- Smart `/` redirect (cookie → Accept-Language → fr) — that's reverse-proxy / BFF work.
- CI gate on missing translations.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #95
2026-05-11 20:48:27 +02:00
julien 8f84cc6389 feat(portal-shell): collapse accessibility routes into one i18n-marked route (#94)
CI / check (push) Successful in 4m0s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m56s
CI / a11y (push) Successful in 2m6s
CI / perf (push) Successful in 3m50s
## Summary

Continue ADR-0019: replace the `/accessibility` + `/accessibilite` twin routes with a single canonical route whose content is i18n-marked in the template. The per-locale build (en/fr) already inlines the right copy — the route-data + `copy()` service indirection is no longer carrying its weight.

## What changes

- **`AccessibilityStatement`** loses its `ActivatedRoute` injection, the `Lang` discriminator, and the `COPY` lookup table. The component is now a plain shell over the template.
- **`accessibility.html`** carries the title + intro + status panel as `i18n="@@page.accessibility.*"` markers. Six new trans-units in [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) provide the French copy — verbatim from the old `COPY.fr` block, so the page reads the same in FR as before.
- **`app.routes.ts`** declares the single canonical route at `path: 'accessibility'` and keeps `/accessibilite` alive as a `redirectTo: 'accessibility'`. Drops `data: { lang: ... }` — no longer consumed.
- **`footer.html`** collapses the dual link into one i18n-marked link (`@@footer.accessibilityLink`). EN bundle reads "Accessibility statement"; FR bundle reads "Déclaration d'accessibilité".

## Decision worth flagging

The path stays in English across both locales for now: `/en/accessibility` and `/fr/accessibility`. Translating route *segments* (`/fr/declaration-d-accessibilite`) needs either a custom URL serializer or per-locale route trees — not worth the complexity at this scale. The page title and the link label already differ per locale via i18n, which is what's actually visible to users.

The historical `/accessibilite` path keeps working via the route-level redirect. Drops out of the codebase once analytics confirm no traffic reaches it.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green. **35 / 35 specs** (was 36; the obsolete `lang`-fallback test is removed).
- [x] Production build emits both locales. FR bundle contains `Statut`, `Déclaration`, the FR intro / panel bodies. No leftover English on swept strings.
- [x] `extract-i18n` clean (49 unique units now: +5 for `page.accessibility.*` + `footer.accessibilityLink`, −0; the old route-data `lang` markers were not i18n).
- [ ] Manual: serve-static then `/en/accessibility` and `/fr/accessibility` render their respective content; `/fr/accessibilite` 301-redirects to `/fr/accessibility`.
- [ ] Manual: footer shows one link, locale-aware ("Accessibility statement" / "Déclaration d'accessibilité").
- [ ] Manual: browser tab title flips between bundles ("Accessibility statement · APF Portal" / "Déclaration d'accessibilité · Portail APF").

## What this PR explicitly does NOT do

- Translate the URL path segment (next ADR-only refinement if needed).
- Add the locale switcher in the footer — that's the next PR on the i18n track.
- Wire a CI gate on missing translations.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #94
2026-05-11 20:18:28 +02:00
julien 65fae7f963 feat(portal-shell): i18n string sweep — mark UI strings + FR translations (#93)
CI / check (push) Successful in 3m20s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m45s
CI / a11y (push) Successful in 1m37s
CI / perf (push) Successful in 3m11s
## Summary

Continue ADR-0019 implementation. Mark every UI string surfaced by the shell with `i18n="@@id"` (templates) or `$localize` (TypeScript), and populate [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) with French translations. The production build now ships **two genuinely different bundles** under `dist/.../{en,fr}/`.

**43 trans-units** marked, grouped by feature:

| Surface | Strings |
|---|---|
| `app.html` | skip-link |
| `header.html` | wordmark, search label + placeholder, action button aria-labels, user-menu placeholder |
| `sidebar.ts` + `.html` | menu groups + items, aside / nav aria-labels, role badge, toggle button (Expand / Collapse aria + Collapse text) |
| `theme-switcher.ts` + `.html` | mode labels, menu aria, trigger aria (`Theme: <mode> (open menu)` with named placeholder) |
| `footer.html` | aria-labels, copyright (interpolation preserved) |
| `home.html` | welcome heading + intro + status widget labels |
| `app.routes.ts` | browser tab titles |

## Tooling

- Add `"@angular/localize"` to the `types` array in both [`tsconfig.app.json`](apps/portal-shell/tsconfig.app.json) and [`tsconfig.spec.json`](apps/portal-shell/tsconfig.spec.json) so TypeScript resolves the `$localize` global at compile time. Specs need it too — they evaluate the same component code paths.
- Extraction target (`nx run portal-shell:extract-i18n`) reports **44 messages** (43 unique IDs).

## Translation choices worth flagging

- **Wordmark**: "APF Portal" → "Portail APF". Same key used for the `/` browser tab title. The PWA manifest (`site.webmanifest`) stays "APF Portal" — manifest values are not bundled, they sit in the static assets and are language-neutral in v1.
- **System theme mode** → "Système".
- **"Anonymous"** role → "Anonyme"; **"Role:"** → **"Rôle :"** (French uses a non-breaking space before the colon — typographic convention, preserved in the XLIFF target).
- **Accessibility links in the footer stay bilingual.** Each carries its own `lang` attribute (`lang="en"` and `lang="fr"`). The dual-link pattern goes away in the upcoming route-fusion PR; until then it's the most honest stopgap.
- **Both accessibility routes share one title key** (`@@route.accessibility.title`). In the EN bundle, both display "Accessibility statement · APF Portal"; in the FR bundle, both display "Déclaration d'accessibilité · Portail APF". After route fusion only one route remains.

## Verification

- Production build: **129 kB gzip initial per locale** (vs 122 kB before). +7 kB absorbs the i18n marker metadata and the embedded translation data in the FR bundle. Well under the 300 KB budget.
- Spot-checked the FR bundle: no leftover English source text on any swept string, route title, or home page intro. The `Welcome to APF Portal` in the lazy `home` chunk shows "Bienvenue sur Portail APF" in FR.
- **36 / 36 specs unchanged.** They run in the source locale (`en`), so the English assertions still match. No spec edits needed.
- Lint clean.

## Out of scope (each its own follow-up PR)

- **Locale switcher in the footer** + `__Host-portal_locale` cookie + smart `/` redirect.
- **Collapse `/accessibility` + `/accessibilite`** into one localised route with locale-translated path segments.
- **CI gate** that fails the build on a missing translation. Once the sweep is reviewed, we add `nx build --localize` to `ci:check` and verify it rejects unsealed strings.
- **Accessibility page content localisation.** Its content is driven by a `copy()` service rather than i18n-marked templates — restructured during the route fusion.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs).
- [x] `pnpm exec nx run portal-shell:extract-i18n` — clean, 43 unique unit IDs.
- [x] Production build emits both locales; FR bundle contains "Tableau de bord", "Aller au contenu principal", etc.
- [ ] Manual: `pnpm exec nx serve portal-shell` shows the EN source. `pnpm exec nx build portal-shell --configuration=production && pnpm exec nx run portal-shell:serve-static` → open `http://localhost:4200/fr/` for FR, `/en/` for EN.
- [ ] Manual: every aria-label announced by a screen reader in FR build matches the French translation.
- [ ] Manual: browser tab title flips between bundles ("APF Portal" vs "Portail APF").

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #93
2026-05-11 19:54:43 +02:00
julien 8b986f3dc3 feat(portal-shell): thin full-width footer with copyright + a11y links (#88)
CI / check (push) Successful in 2m51s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m45s
CI / a11y (push) Successful in 1m9s
CI / perf (push) Successful in 2m50s
## Summary

- Re-add a **40 px** (h-10) footer pinned to the bottom of the shell, spanning the full viewport width below header + sidebar + main.
- Hosts the **`© <year> APF France handicap`** copyright on the left and the FR + EN accessibility statement links on the right, separated by a thin divider.
- **Move the accessibility links out of the sidebar bottom.** Putting legal / compliance links in the footer matches universal web convention and keeps the sidebar focused on product navigation.

## Why footer rather than the help menu

The header's `circle-help` icon will eventually open a help menu (FAQ, keyboard shortcuts, contact support) — that's **product help**, not **legal / compliance**. Auditors (RGAA 4.1, EN 301 549) look for the accessibility statement in the footer; hiding it inside a help dropdown would hurt discoverability. The footer is the canonical home.

## Why both FR + EN stay visible

There is no language toggle yet — `@angular/localize` and its ADR are a separate chantier. Until then, exposing both languages prevents a francophone user from landing on the EN page (or vice versa) via a stale favourite. Once the locale switcher lands, the footer drops the link that doesn't match the active locale.

## Layout

```
:host  flex column, h-100vh
├── app-header           shrink-0, h-16
├── div.shell-body       flex-1, flex row
│   ├── app-sidebar      w-{64|16}, h-full (== shell-body)
│   └── main.shell-main  flex-1, overflow-y-auto
└── app-footer           shrink-0, h-10
```

The sidebar now sits *between* header and footer, so the collapse toggle stays flush above the footer at every viewport size. shell-main still owns its own vertical scroll so long content does not push the footer off-screen.

## What this PR reserves for later (placeholder)

- **Language toggle (FR / EN)** — lands once `@angular/localize` is in.
- **Dev-only version badge** — lands once `environment.ts` per ADR-0018 is wired up.

Both belong in the footer; the layout already has slots for them (center / right groupings).

## Accessibility

- `<footer aria-label="Page footer">` landmark.
- Inline text links inside `<nav aria-label="Legal">` with hover underline + visible focus ring (brand primary, 4 px offset). Inline-link exception applies to the 44×44 touch target (ADR-0016).
- Dark mode: white → `dark:bg-gray-900`, gray-500 text → `dark:text-gray-400`, brand-primary-500 hover → `dark:hover:text-brand-primary-300`.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (**36 / 36 specs**, +3 for the footer).
- [x] Production build: **121 kB gzip initial** (unchanged from main).
- [ ] Manual: footer pinned at the bottom in every viewport size; sidebar height tracks shell-body so the collapse button sits just above it.
- [ ] Manual: both `/accessibility` and `/accessibilite` links navigate correctly and get `aria-current="page"` when active.
- [ ] Manual: dark mode → footer surface flips with the rest of the shell.
- [ ] Manual: keyboard — Tab into the footer reaches each link, focus ring is visible, Shift+Tab walks back out.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #88
2026-05-11 11:07:36 +02:00
julien 3a0a9c700d fix(portal-shell): dark mode actually applies to component-scoped surfaces (#87)
CI / check (push) Successful in 3m0s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m15s
CI / a11y (push) Successful in 1m5s
CI / perf (push) Successful in 2m57s
## Bug

Three component stylesheets (`app.scss`, `sidebar.scss`, `theme-switcher.scss`) carried `:where(.dark) &` rules to react to the `<html>.dark` class — the same pattern Tailwind uses internally. **Angular's emulated CSS encapsulation descends into `:where()` and rewrites its contents with the `_ngcontent-XXX` scoping attribute**, so the produced selector looked like:

```css
:where(.dark[_ngcontent-c0]) .shell-main[_ngcontent-c0] { ... }
```

`<html>` carries `.dark` but no `_ngcontent` attribute — the rule never matched.

Visible symptom: the main page background stayed light even when `.dark` was on `<html>` and every Tailwind `dark:` utility in the templates was working correctly. Sidebar hover/focus/active states and the theme-switcher menu surface had the same bug.

## Fix

- **`app.scss` and `sidebar.scss`** switch to `:host-context(.dark)`. The Angular compiler recognises this directive and expands it without forcing the ancestor (`<html>`) to carry the scoping attribute. Compiled output:

  ```css
  .dark[_nghost-c0] .shell-main[_ngcontent-c0],
  .dark [_nghost-c0] .shell-main[_ngcontent-c0] { ... }
  ```

  The second selector matches `<html>.dark` as an ancestor of `<app-root>` (the host) — exactly what we want.

- **`theme-switcher` switches to `ViewEncapsulation.None`**. Its CDK menu opens in an overlay portal appended to `<body>` — outside the component's host subtree — so even `:host-context()` would miss it. With encapsulation disabled, the styles emit globally; the BEM-style class names (`.theme-switcher__menu`, `.theme-switcher__item`, ...) keep the rules contained without leaking.

## Drive-by

- Remove the right border on the `.header__logo-zone`. The visible hairline above the sidebar was extra noise — the alignment of widths already carries the visual relationship to the rail below.

## Why not also rip out the `dark:` Tailwind utilities used in the templates?

They work. They're emitted into the global Tailwind sheet, sit outside view encapsulation, and already handle the `.dark` ancestor lookup via their own selector (`:where(.dark, .dark *)`). The bug was specifically about component-authored CSS *inside* an SCSS file under emulated encapsulation.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (33 / 33 specs).
- [x] Inspect the produced CSS: `:host-context(.dark)` expands to both `.dark[_nghost-XXX]` and `.dark [_nghost-XXX]` selectors, no `_ngcontent` attribute leaked into the `.dark` portion.
- [ ] Manual: pick dark mode → `/` shows the dark page background; sidebar hover / active / focus visibly switches; theme-switcher menu opens with the dark surface.
- [ ] Manual: header no longer shows a vertical hairline between the logo zone and the search/actions cluster.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #87
2026-05-11 10:45:39 +02:00
julien 0ae7e0e23d 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
2026-05-11 03:01:11 +02:00
julien b6aa17f6a0 feat(portal-shell): align header logo zone with the sidebar rail (#85)
CI / check (push) Successful in 3m4s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m49s
CI / a11y (push) Successful in 1m38s
CI / perf (push) Successful in 3m32s
## Summary

- Split the header into two zones: a **logo zone** whose width tracks the sidebar (16 rem expanded / 4 rem collapsed), and the existing search + actions cluster on the right.
- The logo glyph stays at both widths; the "APF Portal" wordmark hides when the rail collapses, mirroring the sidebar's icon-only mode.
- Promote the sidebar's `collapsed` state to a new [`LayoutStateService`](apps/portal-shell/src/app/state/layout-state.service.ts) (Signals + `localStorage`) so header and sidebar share a single source of truth.

## Why a service rather than prop-drilling

Two reasons:

1. The state is **shell-level**, not sidebar-internal — the header now reads it, and future surfaces (right rail, breadcrumbs, command palette) will too. Passing it down via inputs would force `<app-root>` to act as the owner and turn every intermediate component into a relay.
2. As we add other cross-cutting shell state (density, theme, panel pinning, RTL), they all belong in the same place. `LayoutStateService` is the natural collector and stays trivial as long as we don't over-broaden it. v1 ships with one signal — keeping it narrow.

The service is `providedIn: 'root'` (singleton), Signals-only, and owns localStorage persistence — same UX as before, just relocated.

## Decisions worth flagging

- **Widths stay duplicated between `header.scss` and `sidebar.scss`** (16 rem / 4 rem). Extracting a shared SCSS variable would be premature — only two callers, and the coupling is loud (cross-file comment in `header.scss`). If a third surface needs the same widths, we promote to a shared token.
- **Border continuity.** The logo zone's right border and the sidebar's right border share the same x coordinate, so they read as one continuous vertical separator running the full shell height. Same `#e5e7eb` so the seam is invisible.
- **Width transition** matches the sidebar's (`0.18s ease-out`) and is skipped under `prefers-reduced-motion: reduce`.
- **Sidebar component lost its private state.** Its own signal + effect + storage glue is gone; it delegates reads to the service and the toggle click to `layout.toggleSidebar()`. Net `sidebar.ts` shrunk by ~15 lines.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (25 / 25 specs, +6 for the new service spec + header zone tests).
- [x] Production build under bundle budgets (100 kB gzip initial — +0.2 kB vs main).
- [ ] Manual: load `/`, confirm the logo zone's right edge aligns exactly with the sidebar's right edge at both widths.
- [ ] Manual: toggle the sidebar → both columns animate in sync; wordmark hides; logo glyph stays centered in the 4 rem zone.
- [ ] Manual: reload after toggling → both header and sidebar restore to the persisted state.
- [ ] Manual: `prefers-reduced-motion: reduce` → no width animation in either zone.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #85
2026-05-11 01:14:42 +02:00
julien 3371fbd613 feat(portal-shell): app-shell layout with collapsable sidebar (#83)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m37s
CI / check (push) Successful in 2m25s
CI / a11y (push) Successful in 57s
CI / perf (push) Successful in 3m5s
## Summary

- Replace the flat header+main+footer layout with a real app-shell: fixed header on top, collapsable sidebar + scrollable main below. Sidebar state (collapsed / expanded) persists across reloads via `localStorage`.
- Introduce the APF brand palette as Tailwind v4 `@theme` tokens (primary teal `#12546c`, accent orange `#f7a919`) so every utility (`bg-brand-primary-500`, `text-brand-accent-400`, `ring-brand-primary-200`, …) is available from now on.
- Add an `<app-icon>` façade backed by `lucide-angular` for v1. Logical kebab-case names already match the icomoon-sprite convention, so the future migration is a single-file change in `icon.ts` and consumers stay untouched.
- Migrate the accessibility-statement links from the (now-deleted) footer to the bottom of the sidebar.

## Decisions worth flagging

- **Static menu, permission-shaped data.** Items point to `#` placeholders in v1, except *Dashboard* which is `routerLink="/"` so the active-state styling is visible on the home page. The `MenuItem` shape already carries an optional `requiredPermissions: string[]` so the permission-aware filter (PR 2, alongside ADR-0009 auth) plugs in without restructuring.
- **`<app-icon>` over direct lucide imports.** Consumers write `<app-icon name="bell">` rather than importing the lucide pascal-case symbol. When the icomoon sprite lands, only the registry in `icon.ts` changes — templates do not.
- **Sidebar persistence via `localStorage`, not backend.** Zero round-trip, survives reloads, falls back gracefully when storage is blocked (private mode). Eventually mirrored server-side if the user-preferences feature lands.
- **Footer removed entirely.** With the sidebar carrying the FR + EN accessibility-statement links and the role badge, the bottom rail no longer earned its vertical real estate. The version badge moved out for now; it will return as part of a debug/help menu when there's a real release to surface.

## Accessibility (ADR-0016)

- Skip-link preserved (WCAG 2.4.1 *Bypass Blocks*) and restyled in the brand palette.
- Sidebar exposes named landmarks (`<nav aria-label="Sections">`, `<nav aria-label="Accessibility">`) and the collapse button uses `aria-expanded` + a descriptive `aria-label`.
- Active links carry `ariaCurrentWhenActive="page"`.
- All interactive controls (header action buttons, sidebar links/toggle) meet the 44×44 px minimum hit-target.
- Sidebar width transition is skipped under `prefers-reduced-motion: reduce`.
- Lucide SVGs are marked `aria-hidden` (decorative); accessible names live on the parent control.

## Perf (ADR-0017)

- Production build: **100 kB gzip** initial transfer (budget: 300 kB). Lucide imports are tree-shaken — only the ~18 icons actually used ship.

## What this PR explicitly does NOT do

- Wire icon-set migration to icomoon (kept as a deferred swap behind `<app-icon>`).
- Filter the menu by permission (deferred to PR 2 once the auth flow lands).
- Replace the avatar placeholder with a real user menu (waits on ADR-0009).
- Implement the search input behavior (placeholder only; needs a search backend).

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (19 / 19 specs).
- [x] Production build under bundle budgets (100 kB gzip initial).
- [ ] Manual: load `/`, confirm Dashboard appears active in the sidebar, collapse → reload → still collapsed, focus the address bar then Tab → skip-link visible, keyboard-traverse the sidebar.
- [ ] Manual: `prefers-reduced-motion: reduce` → no width animation when toggling.
- [ ] Manual: zoom to 200 % → no horizontal scroll, header search hides at narrow widths (`md:` breakpoint).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #83
2026-05-11 00:36:41 +02:00
julien 0d31937aeb feat(portal-shell): replace nx-welcome with real shell and home + budgets per ADR-0017 (#74)
CI / check (push) Successful in 2m4s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m6s
CI / a11y (push) Successful in 1m14s
CI / perf (push) Successful in 3m3s
## Summary

Cuts the Nx scaffold's `NxWelcome` page (938 LOC of placeholder markup) and lays down the actual portal layout — header, main, footer landmarks plus a skip link — with a real home page and the two RGAA-mandated accessibility statement routes. Bundle budgets in `project.json` are tightened to match ADR-0017.

## What lands

**Layout shell** (`apps/portal-shell/src/app/`):

- `components/header/` — brand link + primary nav landmark stub
- `components/footer/` — accessibility statement links (FR + EN) + version badge
- `app.html` / `app.scss` — flex column with sticky footer + skip link to `#main-content` (WCAG 2.4.1)

**Pages**:

- `pages/home/` — welcome heading + "system status" widget that fetches `/api/health` from the BFF. Doubles as a smoke test of the full SPA → BFF stack: CORS, OTel trace propagation, Pino log correlation. After page load, http://localhost:16686 should show one trace whose root span is the SPA `document_load`, with a child `fetch` span that has its own child `HTTP GET /api/health` BFF span.
- `pages/accessibility/` — single component, content selected by route data (`lang: 'fr' | 'en'`). Mounted at `/accessibility` (en) and `/accessibilite` (fr). v1 carries placeholder copy explicitly framed as "awaiting APF user panel review" — RGAA + ADR-0016 require the routes to exist; the substantive statement (audit grid, gaps list, remediation plan) lands separately.

**Routing & wiring**:

- `app.routes.ts` adds the three routes, all lazy-loaded.
- `app.config.ts` adds `provideHttpClient(withFetch())` so HttpClient delegates to the patched browser `fetch` and the W3C `traceparent` header propagates automatically (per ADR-0012 phase 2).
- `nx-welcome.ts` removed; `app.ts` no longer imports it.

**Bundle budgets** (`apps/portal-shell/project.json`):

| Type                | Limit (raw)  | ADR-0017 (gzip) |
| ------------------- | ------------ | --------------- |
| `initial`           | 1 MB         | ≤ 300 KB        |
| `anyScript`         | 300 KB       | ≤ 100 KB / chunk |
| `anyComponentStyle` | 6 KB         | ≤ 6 KB           |
| `bundle "styles"`   | 150 KB       | ≤ 150 KB         |

`maximumWarning == maximumError` so an overshoot fails the build (matching `type: "error"` in spirit). Angular CLI compares **raw** sizes; ADR-0017 §Confirmation is updated to record the gzip→raw translation and the deferred follow-up that will add a CI check on the actual gzipped transfer size (Angular has no native gzip-mode budget).

## Verified locally

- `pnpm exec nx run-many -t lint test build` → 8 projects green; **12 tests pass** for `portal-shell` across 5 specs (App, Header, Footer, Home, AccessibilityStatement).
- `pnpm nx build portal-shell --configuration=production` → initial bundle **326.99 KB raw / 89.82 KB transfer** (gzip) — well under the ADR-0017 300 KB gzip target. Lazy chunks: home 2.66 KB, accessibility 2.90 KB.
- `pnpm audit` clean.

After `./infra/local/dev.sh up observability` + `pnpm nx serve portal-bff` + `pnpm nx serve portal-shell`, opening http://localhost:4200 shows the new layout, the system-status widget connects to the BFF, and the corresponding trace appears in Jaeger.

## Out of scope (separate PRs)

- **Real RGAA audit content** — APF user-panel review.
- **Design tokens system** in `libs/shared/tokens`.
- **Reusable UI primitives** in `libs/shared/ui`.
- **User-preferences panel** (ADR-0016 §"User-preferences panel").
- **i18n machinery** (`@angular/localize`) — for v1 the FR/EN copy is inlined and route-driven; real i18n is a separate ADR.
- **Env-config** (Angular `environment.ts`) — the hard-coded `http://localhost:3000/api/health` will move there alongside the OTLP endpoint when the env-config PR lands.
- **CI gzip-transfer-size assertion** to complement the raw-size Angular budgets.

## Test plan

- [ ] CI green on this PR.
- [ ] Local: home page renders, system-status widget transitions from "Checking the backend…" to the success state when the BFF is up; falls back to "Backend unreachable" with the BFF stopped.
- [ ] `/accessibility` and `/accessibilite` render the matching language with `<article lang="en|fr">`.
- [ ] Skip link reachable via Tab from address bar; clicking it focuses `#main-content`.
- [ ] Production build size summary roughly matches the figures above (initial total ≈ 90 KB transfer).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #74
2026-05-10 02:38:42 +02:00