diff --git a/CLAUDE.md b/CLAUDE.md
index 6ce92e8..54c7d5c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,13 +51,14 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Admin application (`portal-admin`):** dedicated Angular SPA alongside `portal-shell`, sharing the same `portal-bff` via `/api/admin/*` routes guarded by an Entra `Portal.Admin` role + `@RequireMfa({ freshness: 600 })` at entry. Distinct origin / cookie / session from `portal-shell` (`__Host-portal_admin_session`). v1 modules: CMS for static pages (multilingual), menu management, user list (read-only), audit log viewer. Bundle budget relaxed to ≤ 500 KB gzip (vs 300 KB for `portal-shell`); same a11y + dark-mode baseline. Shared UI primitives (`Icon`, `LayoutStateService`, brand tokens) graduate to `libs/shared/*` as both apps need them — see [ADR-0020](docs/decisions/0020-portal-admin-app.md).
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
- **Documentation site:** `docs/**/*.md` rendered as a separate static site via **VitePress** (Vite-based, Node-only toolchain, Markdown-first). Mermaid diagrams via `vitepress-plugin-mermaid`. Deployed on its own hostname behind the shared reverse-proxy; CI hook on `docs/` changes rebuilds + publishes. Decoupled from the apps — content lives in `docs/`, no in-app Markdown viewer — see [ADR-0022](docs/decisions/0022-docs-site-vitepress.md).
+- **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG `
`/``, `` tabular fallback, colour-blind-safe palettes, AA-contrast text, `prefers-reduced-motion` gate). Bundle stays under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s lazy-chunk cap via per-`d3-*` module tree-shaking. Future bespoke visualisations land in raw D3 inside the same lib — see [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md).
- **Runtime:** Node.js latest LTS major.
## Repository status
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the four lib roots (`libs/feature/`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` on every PR.
-ADRs 0001 → 0022 are accepted and cover the structural, security, observability, quality, i18n, admin-app, and docs-site choices. **Shipped on `main`:**
+ADRs 0001 → 0023 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, and charts choices. **Shipped on `main`:**
- **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI.
- **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)).
@@ -68,6 +69,7 @@ ADRs 0001 → 0022 are accepted and cover the structural, security, observabilit
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — no v1 consumer yet; will land when the first business route needs an Entra-protected API.
- `@RequireMfa()` / `@RequireAdmin()` guards ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md), [ADR-0020](docs/decisions/0020-portal-admin-app.md)) — designed-in, awaiting first consumer route.
- **Docs static-site implementation** ([ADR-0022](docs/decisions/0022-docs-site-vitepress.md)) — ADR accepted, chantier (VitePress install + `.vitepress/config.ts` + `docs-site.yml` workflow) lands next.
+- **Charts lib + audit-page dashboards** ([ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md)) — ADR accepted; chantier next: `libs/shared/charts/` foundations + 3 starter components (bar, donut, stacked-bar), then `/audit`-page integration with daily-volume + outcome-breakdown + event-type-over-time charts.
- **Strategic security baseline ADR** — separate from the implementation-level [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md). Remains **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly NIS 2). When it lands it will either confirm 0021 or supersede pieces of it.
## Commands once the workspace exists
diff --git a/docs/decisions/0023-charts-d3-observable-plot.md b/docs/decisions/0023-charts-d3-observable-plot.md
new file mode 100644
index 0000000..3c4cdcb
--- /dev/null
+++ b/docs/decisions/0023-charts-d3-observable-plot.md
@@ -0,0 +1,170 @@
+---
+status: accepted
+date: 2026-05-16
+decision-makers: R&D Lead
+tags: [frontend, accessibility, performance]
+---
+
+# Charts and dashboards — D3 + Observable Plot wrapped in `libs/shared/charts`
+
+## Context and Problem Statement
+
+The first dashboards land soon — the audit-log page wants charts for outcome breakdown and daily volume, more business modules will follow. Both `portal-shell` and `portal-admin` will consume the same chart vocabulary (bar, donut, line, stacked-bar, plot/scatter…) and we need:
+
+- a stable, recognized toolkit per [CLAUDE.md](../../CLAUDE.md) §"Project rules";
+- charts that meet [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)'s WCAG 2.2 AA baseline + targeted AAA — i.e. screen-reader-accessible, keyboard-navigable, colour-blind-safe, dark-mode aware;
+- a single shared lib `libs/shared/charts/` so each chart type is written once and consumed by both SPAs.
+
+The original ask cited [D3.js](https://d3js.org/) directly. D3 is the most recognized JS visualization toolkit, has been at 7.x for years, MIT-licensed, maintained by a stable Observable Inc. team. It is **the** primitive for data-viz on the web — but it is a _toolkit_, not a chart library. Producing a polished bar / donut / line chart on D3 alone requires hand-coding ~200-300 LOC per chart type (scales, axes, ticks, tooltips, responsive sizing, animations, a11y). Multiplied by 4-5 chart types in v1, the lib becomes a sustained investment before the first dashboard ships.
+
+## Decision Drivers
+
+- **Recognition + maturity.** Per the project's tech bar, pre-1.0 dependencies and one-maintainer projects are rejected.
+- **Ergonomics.** Adding a new chart type to the lib should be ~50 LOC, not 250. The lib's consumers should write ``, not a custom SVG-rendering recipe.
+- **Accessibility — baked in, not bolted on.** Per ADR-0016, every chart in the lib ships with: SVG `` + `` (read as alt-text by screen readers), a tabular-data fallback under `` for non-visual users, colour-blind-safe palettes (Viridis / Cividis for quantitative ordering, ColorBrewer Set2 for categorical), AA-contrast text on axes/labels (≥ 4.5:1), keyboard-reachable focus on interactive elements (tooltips, legend toggles).
+- **Bundle budget.** Per [ADR-0017](0017-performance-budgets-lighthouse-ci.md), lazy chunks cap at 100 KB gzip. The chart lib must tree-shake cleanly so a page using only the bar chart doesn't pay for the donut / stacked-bar code.
+- **Escape hatch.** Sooner or later a bespoke visualization will land that no "standard chart" library covers (sankey of session-flow, heatmap of audit density, dependency graph). The choice must allow that without re-platforming.
+- **Angular signals + zoneless ergonomics.** Wrappers built on the chosen primitive should integrate cleanly with Signals + OnPush, not import a parallel reactivity model that fights the host app.
+- **i18n posture.** Per [ADR-0019](0019-internationalisation-angular-localize.md), axis labels and legends are caller-supplied strings — the lib stays English-only inside and lets each consumer pass localised strings in.
+
+## Considered Options
+
+- **D3 + Observable Plot wrapped in `libs/shared/charts`** (chosen).
+- **D3 alone**, every chart hand-written on top.
+- **Apache ECharts** — feature-complete chart library, JSON-config driven.
+- **Chart.js** — canvas-based, mature, narrower scope than ECharts.
+
+## Decision Outcome
+
+Chosen: **D3 + Observable Plot, wrapped in a new Nx lib `libs/shared/charts/`**.
+
+The combination plays each tool to its strength:
+
+- **D3** stays available as the lower-level primitive for custom visualisations. Tree-shakable per-module imports (`d3-scale`, `d3-shape`, `d3-array`, `d3-axis`, `d3-selection`) so the bundle only carries what each chart needs.
+- **Observable Plot** (`@observablehq/plot`) is the same team's higher-level layer built _on top of_ D3. Plot reduces "standard chart" code from ~250 LOC to ~50 LOC per type, follows the grammar-of-graphics shape (marks + scales + facets) and stays declarative. It hit 1.0 in 2024, is MIT-licensed, maintained by Observable Inc. (the D3 creators), and is used internally by the same team that maintains D3.
+- **`libs/shared/charts/`** is the Angular adaptation layer. One component per chart type (``, ``, ``, ``, …), each wrapping Plot's `Plot.plot({…})` call and adding the a11y / dark-mode / responsive contract this ADR enforces (see below). Custom-viz components written in raw D3 live in the same lib so consumers see a uniform `` surface regardless of the underlying technique.
+
+### Lib layout
+
+```
+libs/shared/charts/
+├── src/
+│ ├── lib/
+│ │ ├── bar-chart/ (Plot-backed)
+│ │ ├── donut-chart/ (Plot-backed)
+│ │ ├── line-chart/ (Plot-backed)
+│ │ ├── stacked-bar-chart/ (Plot-backed)
+│ │ └── _internal/ (a11y helpers, palette, theme integration)
+│ └── index.ts
+├── project.json
+└── tsconfig.lib.json
+```
+
+`_internal/` carries the cross-component plumbing: the tabular-fallback renderer, the dark-mode-aware palette resolver, the SVG `` + `` builders. Single source of truth so every chart inherits the same a11y contract.
+
+### Component API contract
+
+Every chart component honours the same minimal input shape:
+
+```ts
+[data]: readonly T[]; // raw rows
+[caption]: string; // visible caption + SVG
+[description]: string; // SVG , screen-reader long form
+[ariaLabel]: string; // outer
's aria-label
+[colorScheme]?: 'sequential' | 'categorical'; // chooses the colour-blind-safe palette
+```
+
+Chart-specific inputs (`xKey`, `yKey`, `categoryKey`, `valueKey`, …) layer on top per chart type but keep the same Signal-based shape.
+
+### Accessibility contract — baked in for v1
+
+Each chart component, regardless of which (Plot or D3), produces:
+
+1. An outer `
` wrapping the SVG.
+2. SVG `` + `` elements as the first two children — picked up by screen readers as natural alt-text.
+3. A `` disclosure with a `
` representation of the same dataset, collapsed by default. The table is the keyboard-navigable, screen-reader-friendly fallback when the visual encoding fails (assistive tech, no-JS, print).
+4. Colour palettes from a fixed set: Viridis / Cividis for quantitative orderings, ColorBrewer Set2 for categorical. Both are colour-blind-safe per the ColorBrewer review.
+5. Axis tick + legend text rendered at 12 px minimum, contrast tested against the brand-primary background in both light and dark mode (AA: ≥ 4.5:1).
+6. Animations gated on `prefers-reduced-motion` per ADR-0016 — a user with `reduce` motion gets a static render.
+
+These six commitments are unit-tested in the lib so a future contributor can't ship a chart that quietly drops one of them.
+
+### Bundle impact
+
+Per ADR-0017, lazy chunks cap at 100 KB gzip. Estimated bundle additions for the v1 vocabulary:
+
+| Module | Approx gzip |
+| ------------------------------------------------------------------------------- | ----------- |
+| `d3-scale` + `d3-axis` + `d3-selection` + `d3-shape` + `d3-array` (tree-shaken) | ~25 KB |
+| `@observablehq/plot` | ~30 KB |
+| `libs/shared/charts` (4 components + helpers) | ~10 KB |
+| **Total added to a chart-consuming lazy chunk** | **~65 KB** |
+
+Each individual chart on a page adds ~2-3 KB of generated component code, well below the threshold.
+
+### Consequences
+
+- Good, because the chart lib ships fast — Plot's declarative API means a new chart type is ~50 LOC of Angular wrapper + a Plot call.
+- Good, because D3 stays available as the lower layer for custom visualisations without re-platforming. The same lib hosts both Plot-backed and D3-backed components under a uniform `` API.
+- Good, because the a11y contract is centralised — every consumer gets WCAG 2.2 AA + AAA-targeted compliance without re-deriving it per chart.
+- Good, because the bundle footprint stays well within ADR-0017's lazy-chunk budget even with all four v1 chart types loaded.
+- Good, because both Plot and D3 are maintained by the same team (Observable Inc.), under the same licence (MIT), with overlapping release cadence — no two-vendor coordination problem.
+- Bad, because a fifth dependency adds to the maintenance / vuln surface. Mitigated by the existing Renovate cadence + the `pnpm.overrides` recipe documented in [docs/development.md](../development.md) for transitive-vuln remediation.
+- Bad, because the lib's a11y contract is _our_ commitment, not enforced by Plot itself — a sloppy contributor could bypass `_internal/` and ship a non-conforming chart. Mitigated by the unit-test-based confirmation below + code review.
+- Neutral, because Plot's declarative API is less familiar to D3-only practitioners; the migration cost is a couple of days of reading the Plot manual.
+
+### Confirmation
+
+- A unit test per chart component asserts:
+ - the outer `
` carries `role="img"` + `aria-labelledby` + `aria-describedby`;
+ - the SVG contains a `` and a `` populated from inputs;
+ - a `` block with a `
` fallback exists and contains every data point;
+ - the rendered SVG uses a palette from the canonical `_internal/palette.ts` list (not arbitrary colours).
+- A workspace-wide ESLint rule (custom or via `@typescript-eslint/no-restricted-imports`) bans direct imports of `d3-scale-chromatic` outside `_internal/palette.ts` so consumer-side overrides can't silently use a non-colour-blind-safe palette.
+- Lighthouse CI a11y score remains ≥ 90 on any route that renders a chart (already enforced for the broader app per ADR-0016 + ADR-0017).
+- A storybook-style "all charts in dark + light mode + reduced-motion" page lands in PR 3 so the visual + a11y baseline is exercisable from a single URL.
+
+## Pros and Cons of the Options
+
+### D3 + Observable Plot, wrapped in `libs/shared/charts` (chosen)
+
+- Good, because both libs are MIT, maintained by Observable Inc., past 1.0, used by the same team that builds D3 itself.
+- Good, because Plot covers ~80 % of the chart vocabulary in declarative one-liners, D3 covers the remaining 20 % bespoke cases.
+- Good, because the chart lib stays small (Plot's design philosophy is "do one thing well per mark"), so our wrapper layer can stay correspondingly small.
+- Good, because tree-shaking works cleanly on per-`d3-*` module imports.
+- Bad, because Plot's API isn't a 1:1 port of "every D3 idiom" — there are edge cases (3D, animated transitions across data changes) where you re-implement in D3.
+- Neutral, because we're adding the Vue runtime via VitePress for docs ([ADR-0022](0022-docs-site-vitepress.md)) _plus_ the Observable Plot layer here — two "non-Angular but small" surfaces in the workspace. Both are stable and isolated; no net team-cognitive-load increase.
+
+### D3 alone
+
+- Good, because zero abstraction layer between us and the renderer; full control of every pixel.
+- Good, because the project's existing tech-bar already validates D3 (mature, recognized, ~290k stars across packages).
+- Bad, because each chart type is ~200-300 LOC; v1 ships slower or with less polish.
+- Bad, because the a11y contract has to be re-applied in each chart's code — high risk of drift across components when 3-4 contributors author them over time.
+- Bad, because some "boring" chart features (stacked bar with hover + legend toggling) become 500+ LOC each in D3 alone.
+
+### Apache ECharts
+
+- Good, because feature-complete (50+ chart types out of the box, including the bespoke ones we'd otherwise write).
+- Good, because the JSON config API is uniform across chart types.
+- Bad, because the bundle ships at ~600 KB minified before tree-shaking; carving lazy-chunks requires the `echarts-for-angular` wrapper + manual mark imports.
+- Bad, because ECharts' rendering layer is canvas-by-default — accessibility falls back to a separate `aria` plugin and a non-trivial config block per chart. Reaching WCAG 2.2 AA requires sustained effort, not a flick of a switch.
+- Bad, because the team idiom (JSON-config, no reactivity story for Signals) is the furthest from Angular's declarative-by-Signals direction the rest of the workspace runs on.
+
+### Chart.js
+
+- Good, because canvas-rendered (great perf for large datasets).
+- Good, because the wrapper `ng2-charts` is maintained, Angular-friendly, and reasonably small.
+- Bad, because canvas charts have weaker out-of-the-box accessibility than SVG (no DOM semantics for screen readers; alt-text + table fallback are the only path).
+- Bad, because the chart vocabulary is narrower than Plot's — extending Chart.js with a custom chart type involves canvas rendering primitives, harder than dropping to D3 from Plot.
+- Bad, because Chart.js's animation + tooltip styling is brittle to dark-mode integration — most consumers end up writing one-off CSS overrides.
+
+## More Information
+
+- **Phasing**: implementation lands as a separate chantier after this ADR (lib foundations + 3 starter chart components in one PR, then audit-page integration in a follow-up).
+- **Future chart types** (heatmap, sankey, treemap, geo) ship in raw D3 inside `libs/shared/charts/` as the use case appears. The component contract above is the only requirement; the rendering technique is implementation detail.
+- **Documentation**: the lib gets its own section in [docs/development.md](../development.md) §"Charts" once the implementation lands, plus a Storybook-style demo page per the Confirmation section.
+- **Related ADRs**:
+ - [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) — supplies the WCAG 2.2 AA bar this lib enforces.
+ - [ADR-0017](0017-performance-budgets-lighthouse-ci.md) — supplies the lazy-chunk budget the lib stays under.
+ - [ADR-0019](0019-internationalisation-angular-localize.md) — caller-supplied i18n strings means the lib is locale-free.
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index e12c49e..10fd0b9 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -66,3 +66,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
| [0020](0020-portal-admin-app.md) | `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF | accepted | `frontend`, `backend`, `security`, `infrastructure`, `process` | 2026-05-11 |
| [0021](0021-phase-2-security-baseline.md) | Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope | accepted | `security`, `backend` | 2026-05-13 |
| [0022](0022-docs-site-vitepress.md) | Documentation site — VitePress + Mermaid plugin, separate static deployment | accepted | `process`, `infrastructure` | 2026-05-15 |
+| [0023](0023-charts-d3-observable-plot.md) | Charts + dashboards — D3 + Observable Plot wrapped in `libs/shared/charts` | accepted | `frontend`, `accessibility`, `performance` | 2026-05-16 |