Files
apf_portal/apps/portal-shell/project.json
T
Julien Gautier 65671d9512
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
feat(portal-shell): chatbot widget — SSE-bridged AI assistant with full a11y uplift
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

121 lines
3.1 KiB
JSON

{
"name": "portal-shell",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/portal-shell/src",
"tags": ["scope:portal-shell", "type:app"],
"i18n": {
"sourceLocale": {
"code": "en",
"baseHref": "/en/"
},
"locales": {
"fr": {
"translation": "apps/portal-shell/src/locale/messages.fr.xlf",
"baseHref": "/fr/"
}
}
},
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/portal-shell",
"browser": "apps/portal-shell/src/main.ts",
"polyfills": ["@angular/localize/init"],
"tsConfig": "apps/portal-shell/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "apps/portal-shell/public"
}
],
"styles": ["apps/portal-shell/src/styles.css"]
},
"configurations": {
"production": {
"localize": ["en", "fr"],
"i18nMissingTranslation": "error",
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "1mb"
},
{
"type": "anyScript",
"maximumWarning": "300kb",
"maximumError": "300kb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "8kb"
},
{
"type": "bundle",
"name": "styles",
"maximumWarning": "150kb",
"maximumError": "150kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"extract-i18n": {
"executor": "@angular/build:extract-i18n",
"options": {
"buildTarget": "portal-shell:build",
"outputPath": "apps/portal-shell/src/locale",
"outFile": "messages.xlf"
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "portal-shell:build:production"
},
"development": {
"buildTarget": "portal-shell:build:development"
}
},
"defaultConfiguration": "development"
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
},
"configurations": {
"watch": {
"watch": true
}
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "portal-shell:build",
"port": 4200,
"staticFilePath": "dist/apps/portal-shell/browser"
}
}
}
}