From 0d31937aeb452b6598bb5164070b2dc904076ede Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sun, 10 May 2026 02:38:42 +0200 Subject: [PATCH] feat(portal-shell): replace nx-welcome with real shell and home + budgets per ADR-0017 (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `
`. - [ ] 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 Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/74 --- apps/portal-shell/project.json | 17 +- apps/portal-shell/src/app/app.config.ts | 7 + apps/portal-shell/src/app/app.html | 8 +- apps/portal-shell/src/app/app.routes.ts | 26 +- apps/portal-shell/src/app/app.scss | 32 + apps/portal-shell/src/app/app.spec.ts | 14 +- apps/portal-shell/src/app/app.ts | 14 +- .../src/app/components/footer/footer.html | 25 + .../src/app/components/footer/footer.spec.ts | 27 + .../src/app/components/footer/footer.ts | 23 + .../src/app/components/header/header.html | 11 + .../src/app/components/header/header.spec.ts | 27 + .../src/app/components/header/header.ts | 19 + apps/portal-shell/src/app/nx-welcome.ts | 938 ------------------ .../pages/accessibility/accessibility.html | 18 + .../pages/accessibility/accessibility.spec.ts | 40 + .../app/pages/accessibility/accessibility.ts | 77 ++ .../src/app/pages/home/home-status.service.ts | 43 + .../portal-shell/src/app/pages/home/home.html | 32 + .../src/app/pages/home/home.spec.ts | 50 + apps/portal-shell/src/app/pages/home/home.ts | 35 + .../0017-performance-budgets-lighthouse-ci.md | 2 +- 22 files changed, 528 insertions(+), 957 deletions(-) create mode 100644 apps/portal-shell/src/app/components/footer/footer.html create mode 100644 apps/portal-shell/src/app/components/footer/footer.spec.ts create mode 100644 apps/portal-shell/src/app/components/footer/footer.ts create mode 100644 apps/portal-shell/src/app/components/header/header.html create mode 100644 apps/portal-shell/src/app/components/header/header.spec.ts create mode 100644 apps/portal-shell/src/app/components/header/header.ts delete mode 100644 apps/portal-shell/src/app/nx-welcome.ts create mode 100644 apps/portal-shell/src/app/pages/accessibility/accessibility.html create mode 100644 apps/portal-shell/src/app/pages/accessibility/accessibility.spec.ts create mode 100644 apps/portal-shell/src/app/pages/accessibility/accessibility.ts create mode 100644 apps/portal-shell/src/app/pages/home/home-status.service.ts create mode 100644 apps/portal-shell/src/app/pages/home/home.html create mode 100644 apps/portal-shell/src/app/pages/home/home.spec.ts create mode 100644 apps/portal-shell/src/app/pages/home/home.ts diff --git a/apps/portal-shell/project.json b/apps/portal-shell/project.json index 43caed4..0a3a746 100644 --- a/apps/portal-shell/project.json +++ b/apps/portal-shell/project.json @@ -27,13 +27,24 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kb", + "maximumWarning": "1mb", "maximumError": "1mb" }, + { + "type": "anyScript", + "maximumWarning": "300kb", + "maximumError": "300kb" + }, { "type": "anyComponentStyle", - "maximumWarning": "4kb", - "maximumError": "8kb" + "maximumWarning": "5kb", + "maximumError": "6kb" + }, + { + "type": "bundle", + "name": "styles", + "maximumWarning": "150kb", + "maximumError": "150kb" } ], "outputHashing": "all" diff --git a/apps/portal-shell/src/app/app.config.ts b/apps/portal-shell/src/app/app.config.ts index c2a015a..25b4de3 100644 --- a/apps/portal-shell/src/app/app.config.ts +++ b/apps/portal-shell/src/app/app.config.ts @@ -3,6 +3,7 @@ import { provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; +import { provideHttpClient, withFetch } from '@angular/common/http'; import { provideRouter } from '@angular/router'; import { appRoutes } from './app.routes'; @@ -11,5 +12,11 @@ export const appConfig: ApplicationConfig = { provideZonelessChangeDetection(), provideBrowserGlobalErrorListeners(), provideRouter(appRoutes), + // `withFetch()` makes Angular's HttpClient delegate to the browser + // `fetch` API, which is exactly what `@opentelemetry/instrumentation- + // fetch` patches — every HttpClient request gets its own span and + // the W3C `traceparent` header propagated to the BFF + // automatically. The legacy XHR backend would short-circuit that. + provideHttpClient(withFetch()), ], }; diff --git a/apps/portal-shell/src/app/app.html b/apps/portal-shell/src/app/app.html index 2339c6a..9467553 100644 --- a/apps/portal-shell/src/app/app.html +++ b/apps/portal-shell/src/app/app.html @@ -1,2 +1,6 @@ - - + + +
+ +
+ diff --git a/apps/portal-shell/src/app/app.routes.ts b/apps/portal-shell/src/app/app.routes.ts index 8762dfe..42fd526 100644 --- a/apps/portal-shell/src/app/app.routes.ts +++ b/apps/portal-shell/src/app/app.routes.ts @@ -1,3 +1,27 @@ import { Route } from '@angular/router'; -export const appRoutes: Route[] = []; +export const appRoutes: Route[] = [ + { + path: '', + pathMatch: 'full', + loadComponent: () => import('./pages/home/home').then((m) => m.Home), + title: 'APF Portal', + }, + // RGAA + ADR-0016: the accessibility statement must be reachable + // from both locales' canonical paths. Same component, content + // driven by the `lang` route data property. + { + path: 'accessibility', + loadComponent: () => + import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement), + data: { lang: 'en' }, + title: 'Accessibility statement · APF Portal', + }, + { + path: 'accessibilite', + loadComponent: () => + import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement), + data: { lang: 'fr' }, + title: 'Déclaration d’accessibilité · APF Portal', + }, +]; diff --git a/apps/portal-shell/src/app/app.scss b/apps/portal-shell/src/app/app.scss index e69de29..cee7709 100644 --- a/apps/portal-shell/src/app/app.scss +++ b/apps/portal-shell/src/app/app.scss @@ -0,0 +1,32 @@ +// Page-level layout: header + main + footer in a flex column so the +// footer sticks to the viewport bottom even when the main content is +// short. Driven on the host so it covers the whole document. +:host { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully +// visible and focusable when reached via Tab from the address bar. +// Plain CSS rather than the `sr-only` Tailwind utilities so the +// visual state on focus is as predictable as possible across themes. +.skip-link { + position: absolute; + top: -40px; + left: 0.75rem; + z-index: 50; + padding: 0.5rem 0.75rem; + background: #1d4ed8; + color: #fff; + border-radius: 0.25rem; + text-decoration: none; + transition: top 0.15s ease-out; + + &:focus, + &:focus-visible { + top: 0.5rem; + outline: 2px solid #93c5fd; + outline-offset: 2px; + } +} diff --git a/apps/portal-shell/src/app/app.spec.ts b/apps/portal-shell/src/app/app.spec.ts index 1f52a4b..b5e71e6 100644 --- a/apps/portal-shell/src/app/app.spec.ts +++ b/apps/portal-shell/src/app/app.spec.ts @@ -1,18 +1,22 @@ import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { App } from './app'; -import { NxWelcome } from './nx-welcome'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [App, NxWelcome], + imports: [App], + providers: [provideRouter([])], }).compileComponents(); }); - it('should render title', async () => { + it('renders the layout shell — skip link, header, main, footer', async () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Welcome portal-shell'); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('a.skip-link')).not.toBeNull(); + expect(root.querySelector('app-header')).not.toBeNull(); + expect(root.querySelector('main#main-content')).not.toBeNull(); + expect(root.querySelector('app-footer')).not.toBeNull(); }); }); diff --git a/apps/portal-shell/src/app/app.ts b/apps/portal-shell/src/app/app.ts index 1ad2ae6..5617ede 100644 --- a/apps/portal-shell/src/app/app.ts +++ b/apps/portal-shell/src/app/app.ts @@ -1,13 +1,13 @@ -import { Component } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { NxWelcome } from './nx-welcome'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { Header } from './components/header/header'; +import { Footer } from './components/footer/footer'; @Component({ - imports: [NxWelcome, RouterModule], selector: 'app-root', + imports: [RouterOutlet, Header, Footer], templateUrl: './app.html', styleUrl: './app.scss', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class App { - protected title = 'portal-shell'; -} +export class App {} diff --git a/apps/portal-shell/src/app/components/footer/footer.html b/apps/portal-shell/src/app/components/footer/footer.html new file mode 100644 index 0000000..f8ea508 --- /dev/null +++ b/apps/portal-shell/src/app/components/footer/footer.html @@ -0,0 +1,25 @@ + diff --git a/apps/portal-shell/src/app/components/footer/footer.spec.ts b/apps/portal-shell/src/app/components/footer/footer.spec.ts new file mode 100644 index 0000000..abffa02 --- /dev/null +++ b/apps/portal-shell/src/app/components/footer/footer.spec.ts @@ -0,0 +1,27 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { Footer } from './footer'; + +describe('Footer', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Footer], + providers: [provideRouter([])], + }).compileComponents(); + }); + + it('renders both accessibility statement links (FR + EN)', async () => { + const fixture = TestBed.createComponent(Footer); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('a[href="/accessibility"]')).not.toBeNull(); + expect(root.querySelector('a[href="/accessibilite"]')).not.toBeNull(); + }); + + it('shows the version badge', async () => { + const fixture = TestBed.createComponent(Footer); + await fixture.whenStable(); + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('vdev'); + }); +}); diff --git a/apps/portal-shell/src/app/components/footer/footer.ts b/apps/portal-shell/src/app/components/footer/footer.ts new file mode 100644 index 0000000..91db109 --- /dev/null +++ b/apps/portal-shell/src/app/components/footer/footer.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +/** + * Bottom-of-page utility links. v1 ships: + * - Accessibility statement link (RGAA-mandated, both locales) per + * ADR-0016. + * - Build/version badge for support triage. + * + * Same app-level placement as the header — promotion to + * libs/shared/ui/ when a second consumer materialises. + */ +@Component({ + selector: 'app-footer', + imports: [RouterLink], + templateUrl: './footer.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Footer { + // Hard-coded for now; swapped to a build-time injection once the + // env-config story (Angular `environment.ts`) lands. + protected readonly version = 'dev'; +} diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html new file mode 100644 index 0000000..100ec4b --- /dev/null +++ b/apps/portal-shell/src/app/components/header/header.html @@ -0,0 +1,11 @@ +
+ +
diff --git a/apps/portal-shell/src/app/components/header/header.spec.ts b/apps/portal-shell/src/app/components/header/header.spec.ts new file mode 100644 index 0000000..b49c058 --- /dev/null +++ b/apps/portal-shell/src/app/components/header/header.spec.ts @@ -0,0 +1,27 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { Header } from './header'; + +describe('Header', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Header], + providers: [provideRouter([])], + }).compileComponents(); + }); + + it('renders the brand link to /', async () => { + const fixture = TestBed.createComponent(Header); + await fixture.whenStable(); + const link = fixture.nativeElement.querySelector('a[href="/"]') as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.textContent?.trim()).toBe('APF Portal'); + }); + + it('exposes a primary navigation landmark', async () => { + const fixture = TestBed.createComponent(Header); + await fixture.whenStable(); + const nav = fixture.nativeElement.querySelector('nav[aria-label="Primary"]'); + expect(nav).not.toBeNull(); + }); +}); diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts new file mode 100644 index 0000000..7588b85 --- /dev/null +++ b/apps/portal-shell/src/app/components/header/header.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +/** + * Top-of-page banner. v1 holds only the brand link to "/" and the + * primary navigation landmark — the live navigation grows once we + * have real authenticated pages (per ADR-0009). + * + * Lives at app level (not in libs/shared/ui/) on purpose: there is + * one consumer (portal-shell). Promotion to the shared lib happens + * when a second app needs it. + */ +@Component({ + selector: 'app-header', + imports: [RouterLink], + templateUrl: './header.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Header {} diff --git a/apps/portal-shell/src/app/nx-welcome.ts b/apps/portal-shell/src/app/nx-welcome.ts deleted file mode 100644 index 66d2abb..0000000 --- a/apps/portal-shell/src/app/nx-welcome.ts +++ /dev/null @@ -1,938 +0,0 @@ -import { Component, ViewEncapsulation } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-nx-welcome', - imports: [CommonModule], - template: ` - - - - -
-
- -
-

- Hello there, - Welcome portal-shell 👋 -

-
- -
-
-

- - - - You're up and running -

- What's next? -
-
- - - -
-
- - - -
-

Next steps

-

Here are some things you can do with Nx:

-
- - - - - Build, test and lint your app - -
# Build
-nx build 
-# Test
-nx test 
-# Lint
-nx lint 
-# Run them together!
-nx run-many -t build test lint
-
-
- - - - - View project details - -
nx show project portal-shell
-
- -
- - - - - View interactive project graph - -
nx graph
-
- -
- - - - - Add UI library - -
# Generate UI lib
-nx g @nx/angular:lib ui
-# Add a component
-nx g @nx/angular:component ui/src/lib/button
-
-
-

- Carefully crafted with - - - -

-
-
- `, - styles: [], - encapsulation: ViewEncapsulation.None, -}) -export class NxWelcome {} diff --git a/apps/portal-shell/src/app/pages/accessibility/accessibility.html b/apps/portal-shell/src/app/pages/accessibility/accessibility.html new file mode 100644 index 0000000..fcdad37 --- /dev/null +++ b/apps/portal-shell/src/app/pages/accessibility/accessibility.html @@ -0,0 +1,18 @@ +
+

{{ copy().pageTitle }}

+ +
+

{{ copy().introTitle }}

+

{{ copy().introBody }}

+
+ +
+

+ {{ copy().panelNoticeTitle }} +

+

{{ copy().panelNoticeBody }}

+
+
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.