Commit Graph

8 Commits

Author SHA1 Message Date
Julien Gautier 65671d9512 feat(portal-shell): chatbot widget — SSE-bridged AI assistant with full a11y uplift
CI / scan (pull_request) Successful in 3m51s
CI / commits (pull_request) Successful in 3m53s
CI / check (pull_request) Successful in 4m18s
CI / a11y (pull_request) Successful in 2m28s
CI / perf (pull_request) Successful in 5m59s
Closes the AI relay chantier opened by ADR-0024 by landing the SPA
side: a floating-launcher + dialog-panel chatbot that consumes the
BFF's POST /api/ai/chat SSE endpoint shipped in #196.

Stargate's POC widget was rebuilt from scratch rather than ported —
its drag UX, missing aria-live, and minimal screen-reader contract
did not meet ADR-0016's WCAG 2.2 AA + targeted AAA bar.

Key UX + a11y decisions:

- No drag. Stargate's mouse-only floating-bubble drag had no
  keyboard equivalent; replaced with a fixed bottom-right launcher
  + windowed/fullscreen toggle.
- role=dialog + aria-modal=false (non-modal) so the page stays
  operable behind the panel; focus returns to the launcher on
  close via a viewChild + effect pair.
- role=log + aria-live=polite + aria-relevant=additions on the
  message list, so screen readers announce incoming tokens
  without re-reading the full history. Typing-dots animation is
  decorative (aria-hidden) and gated on prefers-reduced-motion.
- Every interactive element honours the 44 × 44 touch-target
  minimum (launcher, header buttons, send/stop, citation chips).
- Stop button visible during streaming, wired to AbortController →
  ChatClient → upstream LLM cancel.
- Citations rendered as inline footnote chips; clicking opens a
  side-panel sibling component with source + score + snippet.
- All strings tagged with $localize per ADR-0019; FR + EN catalogue
  entries added.
- 5 new icons in the shared registry: maximize-2, message-circle,
  minimize-2, send, square, x.

Architecture:

- ChatbotApiService — native fetch (HttpClient buffers streaming)
  + ReadableStream parser + manual CSRF cookie read + AbortSignal.
- SSE parser — pure function with CRLF tolerance, partial-frame
  reassembly, and well-formed JSON / passthrough decoding.
- ChatbotService — signal-based state (view / messages / streaming
  / selected citation) with stop() / send() actions; ephemeral
  conversation per session reload.
- ChatbotHost component — host of the widget; ChatbotCitationPanel
  extracted as a sibling component to stay under
  anyComponentStyle budget (raised from 5/6 to 6/8 KB to match
  portal-admin's posture).
- Lazy-loaded via @defer (on idle) in app.html so the chatbot's
  ~27 KB lazy chunk does not weigh on the initial bundle.

Tests: 27 new specs covering the SSE parser (8), API service (5),
state service (10), and host component (12 — launcher / dialog /
empty state / messages / log live region / input / citation panel).
2026-05-19 23:37:10 +02:00
julien 99522540a5 feat(portal-shell): ci gate — fail prod build on missing translations (#98)
CI / check (push) Successful in 2m54s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m16s
CI / a11y (push) Successful in 1m24s
CI / perf (push) Successful in 4m3s
## Summary

Set `"i18nMissingTranslation": "error"` on the production build configuration in [`apps/portal-shell/project.json`](apps/portal-shell/project.json). The Angular CLI checks every `<source>` extracted from `i18n` markers and `$localize` calls against the loaded translation file (`messages.fr.xlf`); with the flag on `error`, the build now **fails** when a target is missing instead of silently falling back to the source text in the FR bundle.

Closes the loop on ADR-0019 §"Confirmation" — the final piece of the i18n track that does not depend on the BFF route + cookie work (deferred to the auth-flow chantier).

## Gate mechanics

- `pnpm ci:check` runs `nx affected -t ... build`, which uses the production configuration by default. A PR that adds an `i18n` marker without updating `messages.fr.xlf` fails its build job → merge blocked.
- The default build configuration is unchanged (`production`), so no other CI plumbing is needed.

## Verification

Locally, temporarily inserting an unmarked-in-fr string:

```html
<span i18n="@@sanity.test.missingTranslation">A string with no FR translation</span>
```

then running `pnpm exec nx build portal-shell --configuration=production` produces:

```
ERROR: No translation found for "sanity.test.missingTranslation"
       ("A string with no FR translation").
Application bundle generation failed.
NX   Running target build for project portal-shell failed
```

…with a non-zero exit code. The patch was reverted before commit.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (40 / 40 specs, build still passes on the current state).
- [x] Sanity-check that the gate actually fires when a translation is missing (above).
- [ ] CI: the next PR that adds an `i18n` marker without updating `messages.fr.xlf` should fail at the `build` step with the explicit error.

## What this PR explicitly does NOT do

- Catch **unmarked** source strings (e.g. a developer who forgets to add `i18n="@@..."` to a new template line). That would need a custom ESLint rule against the Angular template AST — a separate, smaller scope mentioned in ADR-0019 as a future refinement.
- Localise editorial / CMS content. Editorial copy comes from the BFF already localised; this gate covers only the developer-owned UI strings baked at build time.
- Add a similar gate to the i18n extraction (we could fail the build if `nx extract-i18n` produces diffs against the committed `messages.xlf`, but we don't commit `messages.xlf` today).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #98
2026-05-12 00:30:27 +02:00
julien 04675b1b59 fix(ci): perf job — point Lighthouse at /fr/ and /en/, drop spa fallback (#92)
CI / check (push) Successful in 2m56s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m23s
CI / a11y (push) Successful in 1m34s
CI / perf (push) Successful in 3m51s
## Summary

The previous PR (#91) enabled `--localize` on the production build, so the output layout became `dist/apps/portal-shell/browser/{en,fr}/` with **no top-level `index.html`**. The `perf` CI job broke in two places downstream:

1. **`nx run portal-shell:serve-static`** had `spa: true`. The `@nx/web:file-server` executor reads that as "copy `<staticFilePath>/index.html` to `404.html` for SPA fallback". The source file no longer exists, so the executor crashed with `ENOENT … copyfile … index.html` before opening the port. lhci then failed its healthcheck and exited 1.
2. **`lighthouserc.js`** was hitting `http://localhost:4200/`, which now lands on `http-server`'s directory listing (no index.html at that path). Even if the server had started, the audit would have measured the wrong page.

## What changes

- **Drop `spa: true`** from the `serve-static` target in [`apps/portal-shell/project.json`](apps/portal-shell/project.json). Deep-link fallback in production is the reverse proxy's job (it routes `/{en,fr}/anything` to the matching `index.html`); `nx serve-static` is only used here for the perf gate and for local prod-build inspection of entry points. For deep-link testing in dev, `nx serve` is the right tool.
- **Update [`lighthouserc.js`](lighthouserc.js)** `url` list to `['http://localhost:4200/fr/', 'http://localhost:4200/en/']`, matching the directive in ADR-0019 that both locales clear the same performance bar.

## Verification

Local repro (against the merged plumbing PR's build):

```
$ pnpm exec nx run portal-shell:serve-static
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4200/en/   # 200
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4200/fr/   # 200
```

Served files have the right metadata per locale:

```
/tmp/probe-en.html: lang="en"   <base href="/en/">
/tmp/probe-fr.html: lang="fr"   <base href="/fr/">
```

## Side-effect to call out

- `/en/deep/route` and `/fr/deep/route` now return 404 from `nx serve-static`. That's by design — Lighthouse only audits the root locale URLs, and the reverse proxy owns deep-link routing in production.
- `http://localhost:4200/` returns http-server's directory listing under the new layout. Lighthouse doesn't hit it, so the perf gate is unaffected. We could disable the listing if it becomes a footgun.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green.
- [x] Local `nx serve-static` + curl against `/en/` and `/fr/` returns the expected per-locale `index.html`.
- [ ] CI: `pnpm ci:perf` runs through `serve-static` start → Lighthouse autorun (×3 per locale, ×2 locales = 6 audits) → assertions hold ≥ 90 on Performance for both.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #92
2026-05-11 17:18:52 +02:00
julien 29d16c7527 feat(portal-shell): wire @angular/localize plumbing per ADR-0019 (#91)
CI / check (push) Successful in 2m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m42s
CI / a11y (push) Successful in 1m30s
CI / perf (push) Failing after 56s
## Summary

First implementation step of ADR-0019. Wire the `@angular/localize` plumbing into `portal-shell` so the next sweep PR can start marking UI strings without any infrastructure work.

## What changes

- **Promote `@angular/localize` to a direct dependency** (it was already a transitive via the Angular metapackage; promoting it makes the `init` polyfill explicitly resolvable from the project).
- **Configure the `i18n` block** in [`apps/portal-shell/project.json`](apps/portal-shell/project.json):
  - `sourceLocale: { code: "en", baseHref: "/en/" }` — matches the project English-only rule.
  - `locales.fr: { translation: "...messages.fr.xlf", baseHref: "/fr/" }` — single target locale for now.
- **Add the `init` polyfill** to the build target (`"polyfills": ["@angular/localize/init"]`).
- **Add an `extract-i18n` Nx target** that wraps Angular's `@angular/build:extract-i18n` executor and drops the source XLF next to the translation files.
- **Enable `--localize` on the production build** — `nx build portal-shell --configuration=production` now emits two folders side by side: `dist/apps/portal-shell/browser/en/` and `.../fr/`. Each carries its own `<html lang>` and `<base href>` per the ADR.
- **Seed an empty `messages.fr.xlf`** (XLIFF 1.2 skeleton with sourceLanguage="en" / targetLanguage="fr" and an inline editor convention note). The sweep PR drops `<trans-unit>` entries directly into the body block.

## Verification

```
dist/apps/portal-shell/browser/
├── en/
│   └── index.html   ← <html lang="en">, <base href="/en/">
└── fr/
    └── index.html   ← <html lang="fr">, <base href="/fr/">
```

Until the sweep PR marks strings, both bundles ship the same English source text — that's expected and matches what the ADR calls out ("the FR bundle falls back to source text for every untranslated key").

## What this PR explicitly does NOT do

- **Mark UI strings.** Every `i18n` attribute / `$localize` call lands in the next PR. Pure infra commit here.
- **Locale switcher in the footer** + `__Host-portal_locale` cookie + smart `/` redirect. Lands once switching shows a meaningful difference.
- **Collapse `/accessibility` + `/accessibilite` into a single localised route.** Depends on marked text + localized route paths — sweep PR territory.
- **CI gate** that fails the build on missing translations. Lands when there are translations to be missing.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs unchanged).
- [x] `pnpm exec nx build portal-shell --configuration=production` — produces both locale folders with correct `<html lang>` and `<base href>`.
- [x] `pnpm exec nx run portal-shell:extract-i18n` — runs cleanly, reports `(Messages: 0)` as expected.
- [x] Production initial bundle: **123 kB gzip per locale** (vs 121 kB on `main`; +1.5 kB for the `@angular/localize` runtime polyfill). Both stay well under the 300 KB budget.
- [ ] Manual: `pnpm exec nx serve portal-shell` runs unchanged (source locale `en`, no `--localize` in dev for now).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #91
2026-05-11 16:46:08 +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
Julien Gautier bd8eefb44a chore: configure Tailwind 4 and align lib tsconfigs
Wire Tailwind CSS 4 in the portal-shell app per ADR-0016 (the future
host of spartan-ng components in libs/shared/ui will read from the
Tailwind tokens via the shared-tokens lib).

- pnpm add -D tailwindcss @tailwindcss/postcss postcss
- apps/portal-shell/postcss.config.js declares @tailwindcss/postcss
- apps/portal-shell/src/styles.scss renamed to styles.css and now
  contains a single @import 'tailwindcss' directive. Plain CSS for the
  global file avoids the Sass @import deprecation warning that fires
  when Tailwind directives sit inside SCSS. Component-level styles can
  still use SCSS.
- apps/portal-shell/project.json styles entry updated accordingly.

Side fix: align libs/shared/tokens and libs/shared/util tsconfigs from
module: commonjs to module: esnext. The Nx @nx/js:library --bundler=
tsc generator emits commonjs by default, but tsconfig.base.json
specifies moduleResolution: bundler, which TS only allows alongside
esnext or es2015+ modules. Without the alignment, those libs failed to
build (TS5095). All apps and libs now build green.

spartan-ng wiring is intentionally NOT in this commit. spartan-ng is
currently at 0.0.1-alpha.681 - clearly pre-1.0, which trips the
project rule against pre-1.0 dependencies. ADR-0016 chose spartan-ng
with the copy-paste mitigation, but the alpha state warrants an
explicit go/no-go decision before committing the workspace to it.
2026-04-30 17:50:35 +02:00
Julien Gautier cd1d482aa8 fix(portal-shell): make 'nx test' run once by default, add watch configuration
The Angular 21 unit-test builder (@angular/build:unit-test) defaults
to watch mode. Without an explicit option, 'pnpm nx test portal-shell'
hangs on 'Waiting for task' indefinitely - unsuitable for CI and
surprising for ad-hoc invocations.

Pin watch=false as the default in the target options. Add a 'watch'
configuration so developers who want continuous test running can opt
in with 'pnpm nx test portal-shell --configuration=watch'. portal-bff
uses Jest which defaults to no-watch and needs no change.
2026-04-30 16:44:33 +02:00
Julien Gautier bea5e1954f chore: generate portal-shell and portal-bff apps per ADR-0004 / ADR-0005
Add the @nx/angular, @nx/nest, @nx/vite, @nx/eslint plugins, then
generate the two apps. Adjust the empty-template tsconfig.base.json
to be Angular-compatible (drop project references and customConditions
that the empty-template defaults to but Angular doesn't support; keep
the strict-TS extensions from ADR-0004).

apps/portal-shell (Angular 21):
- standalone APIs, routing, SCSS, esbuild
- vitest-angular as unitTestRunner, playwright for e2e
- strict mode
- tags scope:portal-shell, type:app
- app.config.ts wired with provideZonelessChangeDetection() per
  ADR-0004 (Angular 21 + Nx 22 generates without zone.js by default)

apps/portal-bff (NestJS 11):
- Express adapter (default per ADR-0005)
- Jest as unitTestRunner
- tags scope:portal-bff, type:app
- main.ts wired with a global ValidationPipe configured
  whitelist + forbidNonWhitelisted + transform per ADR-0005
- Phase-2 security additions (helmet, CORS, sessions, CSRF, rate
  limit, auth guards, error filter) deferred to their respective
  ADRs - placeholder comment in main.ts

Workspace dependencies: class-validator + class-transformer added
(required by NestJS ValidationPipe at runtime). Nx-generated
.gitignore additions (.angular, __screenshots__) merged into ours.
.vscode/extensions.json and launch.json added by Nx are kept (do not
override our existing settings.json).
2026-04-30 16:12:42 +02:00