+
+
diff --git a/apps/portal-shell/src/app/pages/accessibility/accessibility.spec.ts b/apps/portal-shell/src/app/pages/accessibility/accessibility.spec.ts
new file mode 100644
index 0000000..c958411
--- /dev/null
+++ b/apps/portal-shell/src/app/pages/accessibility/accessibility.spec.ts
@@ -0,0 +1,40 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+import { AccessibilityStatement } from './accessibility';
+
+function configureWithLang(lang: 'fr' | 'en' | undefined) {
+ return TestBed.configureTestingModule({
+ imports: [AccessibilityStatement],
+ providers: [{ provide: ActivatedRoute, useValue: { data: of({ lang }) } }],
+ }).compileComponents();
+}
+
+describe('AccessibilityStatement', () => {
+ it('renders the French copy on /accessibilite (lang: fr)', async () => {
+ await configureWithLang('fr');
+ const fixture = TestBed.createComponent(AccessibilityStatement);
+ await fixture.whenStable();
+ const root = fixture.nativeElement as HTMLElement;
+ expect(root.querySelector('h1')?.textContent).toContain('Déclaration d’accessibilité');
+ expect(root.querySelector('article')?.getAttribute('lang')).toBe('fr');
+ });
+
+ it('renders the English copy on /accessibility (lang: en)', async () => {
+ await configureWithLang('en');
+ const fixture = TestBed.createComponent(AccessibilityStatement);
+ await fixture.whenStable();
+ const root = fixture.nativeElement as HTMLElement;
+ expect(root.querySelector('h1')?.textContent).toContain('Accessibility statement');
+ expect(root.querySelector('article')?.getAttribute('lang')).toBe('en');
+ });
+
+ it('falls back to English when the route omits the lang', async () => {
+ await configureWithLang(undefined);
+ const fixture = TestBed.createComponent(AccessibilityStatement);
+ await fixture.whenStable();
+ expect((fixture.nativeElement as HTMLElement).querySelector('h1')?.textContent).toContain(
+ 'Accessibility statement',
+ );
+ });
+});
diff --git a/apps/portal-shell/src/app/pages/accessibility/accessibility.ts b/apps/portal-shell/src/app/pages/accessibility/accessibility.ts
new file mode 100644
index 0000000..a7ba1fa
--- /dev/null
+++ b/apps/portal-shell/src/app/pages/accessibility/accessibility.ts
@@ -0,0 +1,77 @@
+import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { map } from 'rxjs';
+
+type Lang = 'fr' | 'en';
+
+interface AccessibilityCopy {
+ htmlLang: Lang;
+ pageTitle: string;
+ introTitle: string;
+ introBody: string;
+ panelNoticeTitle: string;
+ panelNoticeBody: string;
+}
+
+const COPY: Record = {
+ fr: {
+ htmlLang: 'fr',
+ pageTitle: 'Déclaration d’accessibilité',
+ introTitle: 'À propos de cette déclaration',
+ introBody:
+ 'Le portail APF s’engage à rendre ses services numériques accessibles, conformément ' +
+ 'au RGAA 4.1 et à la norme WCAG 2.2 niveau AA, avec des critères AAA ciblés sur les ' +
+ 'points à fort impact pour les usagers de l’association (voir ADR-0016).',
+ panelNoticeTitle: 'Statut du présent document',
+ panelNoticeBody:
+ 'Cette déclaration est en attente de revue et de validation par le panel d’usagers ' +
+ 'd’APF France handicap. La version définitive intégrera la grille d’audit RGAA, le ' +
+ 'recensement des écarts éventuels, le plan de mise en conformité, et les coordonnées ' +
+ 'de signalement.',
+ },
+ en: {
+ htmlLang: 'en',
+ pageTitle: 'Accessibility statement',
+ introTitle: 'About this statement',
+ introBody:
+ 'The APF Portal is committed to making its digital services accessible, in line with ' +
+ 'WCAG 2.2 level AA and the French RGAA 4.1 reference, with targeted AAA criteria on ' +
+ 'the items most impactful to the association’s user base (see ADR-0016).',
+ panelNoticeTitle: 'Status of this document',
+ panelNoticeBody:
+ 'This statement is awaiting review and sign-off by the APF France handicap user ' +
+ 'panel. The final version will integrate the RGAA audit grid, the list of any ' +
+ 'identified gaps, the remediation plan, and the contact details for reporting ' +
+ 'accessibility issues.',
+ },
+};
+
+/**
+ * Accessibility statement, mounted at both `/accessibility` (en) and
+ * `/accessibilite` (fr) per ADR-0016. Same component, content
+ * selected from a route data property.
+ *
+ * v1 ships placeholder copy explicitly framed as "awaiting APF panel
+ * review" — content is required to exist (RGAA + ADR-0016) but the
+ * substance of the statement (audit grid, gaps list, remediation
+ * plan) is owned by the accessibility review and lands separately.
+ */
+@Component({
+ selector: 'app-accessibility-statement',
+ templateUrl: './accessibility.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AccessibilityStatement {
+ private readonly route = inject(ActivatedRoute);
+
+ protected readonly lang = toSignal(
+ this.route.data.pipe(map((data): Lang => (data['lang'] as Lang | undefined) ?? 'en')),
+ // `as Lang` rather than `as const` so the toSignal overload
+ // picks `Signal` instead of `Signal` and downstream
+ // consumers see `Signal` cleanly.
+ { initialValue: 'en' as Lang },
+ );
+
+ protected readonly copy = computed(() => COPY[this.lang()]);
+}
diff --git a/apps/portal-shell/src/app/pages/home/home-status.service.ts b/apps/portal-shell/src/app/pages/home/home-status.service.ts
new file mode 100644
index 0000000..be58888
--- /dev/null
+++ b/apps/portal-shell/src/app/pages/home/home-status.service.ts
@@ -0,0 +1,43 @@
+import { HttpClient } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { catchError, map, of } from 'rxjs';
+
+export interface BackendHealth {
+ status: 'ok';
+ uptimeSeconds: number;
+ service: string;
+ version: string;
+}
+
+export type HomeStatus =
+ | { state: 'loading' }
+ | { state: 'ok'; health: BackendHealth }
+ | { state: 'error' };
+
+/**
+ * Fetches the BFF's `/api/health` once on instantiation and exposes
+ * the result as a Signal. Doubles as a smoke test of the SPA→BFF
+ * stack (CORS, OTel trace propagation, Pino log correlation —
+ * everything we wired in the observability foundation PRs).
+ *
+ * The base URL is hard-coded for now; it moves to Angular's
+ * `environment.ts` mechanism with the env-config PR (same one that
+ * will host the OTLP endpoint).
+ */
+@Injectable({ providedIn: 'root' })
+export class HomeStatusService {
+ private static readonly HEALTH_URL = 'http://localhost:3000/api/health';
+ private readonly http = inject(HttpClient);
+
+ readonly status = toSignal(
+ this.http.get(HomeStatusService.HEALTH_URL).pipe(
+ map((health): HomeStatus => ({ state: 'ok', health })),
+ catchError(() => of({ state: 'error' })),
+ ),
+ // The cast to `HomeStatus` (rather than `as const`) lets toSignal's
+ // overload pick `Signal` instead of `Signal` and keeps
+ // the consuming `.state` narrowing clean for the component.
+ { initialValue: { state: 'loading' } as HomeStatus },
+ );
+}
diff --git a/apps/portal-shell/src/app/pages/home/home.html b/apps/portal-shell/src/app/pages/home/home.html
new file mode 100644
index 0000000..5cac0a2
--- /dev/null
+++ b/apps/portal-shell/src/app/pages/home/home.html
@@ -0,0 +1,32 @@
+
+
Welcome to APF Portal
+
+ This is the placeholder home page for the APF Portal. Real content lands once the comms team and
+ the accessibility review panel sign off on the copy.
+
+
+
+
System status
+ @if (isLoading()) {
+
Checking the backend…
+ } @else if (health(); as h) {
+
+
Service
+
{{ h.service }}
+
Version
+
{{ h.version }}
+
Uptime
+
{{ h.uptimeSeconds }} s
+
Status
+
{{ h.status }}
+
+ } @else if (isError()) {
+
+ Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured.
+
+ }
+
+
diff --git a/apps/portal-shell/src/app/pages/home/home.spec.ts b/apps/portal-shell/src/app/pages/home/home.spec.ts
new file mode 100644
index 0000000..abf5661
--- /dev/null
+++ b/apps/portal-shell/src/app/pages/home/home.spec.ts
@@ -0,0 +1,50 @@
+import { TestBed } from '@angular/core/testing';
+import { signal } from '@angular/core';
+import { Home } from './home';
+import { HomeStatusService, type HomeStatus } from './home-status.service';
+
+describe('Home', () => {
+ let statusSignal: ReturnType>;
+
+ beforeEach(async () => {
+ statusSignal = signal({ state: 'loading' });
+ await TestBed.configureTestingModule({
+ imports: [Home],
+ providers: [{ provide: HomeStatusService, useValue: { status: statusSignal.asReadonly() } }],
+ }).compileComponents();
+ });
+
+ it('renders the welcome heading', async () => {
+ const fixture = TestBed.createComponent(Home);
+ await fixture.whenStable();
+ expect((fixture.nativeElement as HTMLElement).querySelector('h1')?.textContent).toContain(
+ 'Welcome to APF Portal',
+ );
+ });
+
+ it('shows a loading message while the backend health is pending', async () => {
+ const fixture = TestBed.createComponent(Home);
+ await fixture.whenStable();
+ expect((fixture.nativeElement as HTMLElement).textContent).toContain('Checking the backend');
+ });
+
+ it('renders the health details once the backend responds', async () => {
+ statusSignal.set({
+ state: 'ok',
+ health: { status: 'ok', service: 'portal-bff', version: '1.2.3', uptimeSeconds: 42 },
+ });
+ const fixture = TestBed.createComponent(Home);
+ await fixture.whenStable();
+ const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
+ expect(text).toContain('portal-bff');
+ expect(text).toContain('1.2.3');
+ expect(text).toContain('42');
+ });
+
+ it('renders an error message when the backend is unreachable', async () => {
+ statusSignal.set({ state: 'error' });
+ const fixture = TestBed.createComponent(Home);
+ await fixture.whenStable();
+ expect((fixture.nativeElement as HTMLElement).textContent).toContain('Backend unreachable');
+ });
+});
diff --git a/apps/portal-shell/src/app/pages/home/home.ts b/apps/portal-shell/src/app/pages/home/home.ts
new file mode 100644
index 0000000..1721685
--- /dev/null
+++ b/apps/portal-shell/src/app/pages/home/home.ts
@@ -0,0 +1,35 @@
+import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
+import { type BackendHealth, HomeStatusService } from './home-status.service';
+
+/**
+ * Landing page at `/`. Replaces the Nx-generated welcome scaffold.
+ *
+ * v1 carries:
+ * - A welcome heading + brief description (placeholder content
+ * until the comms team / a11y panel reviews and lands the real
+ * copy).
+ * - A "system status" widget that hits the BFF's `/api/health`.
+ * 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.
+ */
+@Component({
+ selector: 'app-home',
+ templateUrl: './home.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class Home {
+ private readonly statusService = inject(HomeStatusService);
+
+ // The discriminated `HomeStatus` does not narrow cleanly through
+ // Angular's template type-checker via `@let`/`@if`, so we expose
+ // three pre-narrowed signals the template can read directly.
+ protected readonly isLoading = computed(() => this.statusService.status().state === 'loading');
+ protected readonly isError = computed(() => this.statusService.status().state === 'error');
+ protected readonly health = computed(() => {
+ const s = this.statusService.status();
+ return s.state === 'ok' ? s.health : null;
+ });
+}
diff --git a/docs/decisions/0017-performance-budgets-lighthouse-ci.md b/docs/decisions/0017-performance-budgets-lighthouse-ci.md
index 7b4754d..0508e32 100644
--- a/docs/decisions/0017-performance-budgets-lighthouse-ci.md
+++ b/docs/decisions/0017-performance-budgets-lighthouse-ci.md
@@ -209,7 +209,7 @@ No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-obs
### Confirmation
- `lighthouserc.js` exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration.
-- `apps/portal-shell/project.json` declares `budgets` of type `"error"` with the values above.
+- `apps/portal-shell/project.json` declares `budgets` with `maximumWarning == maximumError` (so an overshoot fails the build, matching `type: "error"` in spirit). Four entries: `initial` (≤ 1 MB raw), `anyScript` (≤ 300 KB raw), `anyComponentStyle` (≤ 6 KB raw, with 5 KB warning floor), `bundle name=styles` (≤ 150 KB raw). Angular CLI compares **raw** sizes; the values above are translated from the gzip-based ADR targets at the conventional ≈ 3× JS / ≈ 4× CSS compression ratios. A future follow-up will add a CI check that asserts the **actual** gzipped transfer size against the ADR thresholds — Angular CLI does not natively support a gzip-mode budget.
- `package.json` exposes `ci:perf`, runnable locally with the same exit code as CI.
- CI's `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) calls `pnpm ci:perf`. It is blocking on human PRs and on `push` to `main`; skipped on PRs authored by the Renovate bot user (`apf-portal-bot`) per the gating-policy subsection above.
- `apps/portal-shell` exposes a Nx target `analyze` invoking `source-map-explorer` against the production build's source maps.