Commit Graph

3 Commits

Author SHA1 Message Date
julien f2360b9db3 chore(shared-charts): soften the curated palette (#185)
CI / commits (push) Has been skipped
CI / check (push) Successful in 4m50s
CI / scan (push) Successful in 5m22s
CI / a11y (push) Successful in 3m56s
CI / perf (push) Successful in 9m20s
## Summary

Tune the curated chart palette to a softer, lower-saturation set. The values shipped in #175 were pulled straight from Tailwind's `-600 / -700` ramp; on real audit-log data the donut's three slices and the bar-chart's blue read as too punchy when they share a tile, especially in dark mode. Same five intents, same a11y posture — just less visual fight.

## What lands

`libs/shared/charts/src/lib/_internal/palette.ts`:

| Constant                          | Before   | After    |
| --------------------------------- | -------- | -------- |
| `DEFAULT_BAR_FILL`                | `#1d4ed8` | `#4075e7` |
| `semanticStatusColors.info`       | `#2563eb` | `#4075e7` |
| `semanticStatusColors.success`    | `#16a34a` | `#46ac6b` |
| `semanticStatusColors.warning`    | `#ea580c` | `#f38043` |
| `semanticStatusColors.error`      | `#dc2626` | `#eb5252` |
| `semanticStatusColors.neutral`    | `#6b7280` | `#6b7280` (unchanged) |

`info` and `DEFAULT_BAR_FILL` collapse to the same hex — bars and "informational" donut slices are *meant* to read as the same semantic class (no special status), so unifying them at the constant level removes a future drift hazard.

Docstrings updated alongside — the previous comments name-checked Tailwind shades (`green-600`, `tailwind blue-700`) that no longer correspond to the values; the new comments describe the palette by intent (`muted green`, `muted orange`, ...) and call out that the softening is deliberate.

## Notes for the reviewer

- **A11y posture unchanged.** The lib's contract is "AA contrast on white surfaces, deuteranopia/protanopia distinguishability via lightness deltas, not just hue". All four chromatic entries clear the same bar: each lightness sits in a distinct band (≈ 67 % for warning, ≈ 60 % for success, ≈ 60 % for error, ≈ 56 % for info), so colour-blind viewers still distinguish them by brightness even if the hue collapses.
- **Why not derive these from `libs/shared/tokens/brand-tokens.css`?** Brand-primary is the dark teal `#12546c` and brand-accent is `#f7a919`. Neither reads correctly as "success" or "neutral chart fill"; the charts need a categorical palette tuned for *legibility on dense surfaces*, not for chrome and CTAs. Keeping the chart palette in its own lib stays consistent with ADR-0023's "lib owns the palette" stance.
- **Bar fill default + `info` semantic alias to the same value on purpose.** A bar with no per-bar encoding is semantically "informational quantity over time" — the same intent as a donut slice tagged `info`. Future consumer that wants to flag a single "info" bar inside a stacked chart will read the colour as consistent.
- **No code changes outside this file.** Consumers (`<lib-bar-chart>`, `<lib-donut-chart>`, the audit page) import these constants by name; the swap is purely a value change.

## Test plan

- [x] `pnpm nx test shared-charts` — 15 specs pass (the donut `colorMap` spec asserts the *consumer-provided* hexes, not the lib defaults, so the change is transparent there; the bar single-fill spec checks uniqueness, not the specific value).
- [x] `pnpm nx test portal-admin` — 62 specs pass.
- [x] `pnpm nx run-many -t test build lint -p shared-charts,portal-admin` — clean (same three pre-existing lint warnings unrelated to this PR).
- [ ] **Manual smoke** — `pnpm nx serve portal-admin`, sign in with `Portal.Admin`, navigate to `/admin/audit`, switch to Charts:
  - Daily-volume bars render in the new muted blue.
  - Outcome donut slices: green (success), red (failure), orange (denied) — softer than before, semantic mapping intact.
  - Dark-mode toggle — palette still legible against the dark surface.
  - Side-by-side comparison vs `main` — the new shades feel calmer, especially when multiple charts share the viewport.

## What's next

Nothing pending on the palette front. If a future chart needs a sixth intent (e.g. `pending` for in-flight states), add it here with a contrast / colour-blind check and update the typed `SemanticStatus` union in the same PR.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #185
2026-05-17 23:42:07 +02:00
julien 9e7eb4af15 fix(audit): chart colours + Charts-tab layout regressions (#175)
CI / check (push) Successful in 6m42s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m14s
CI / a11y (push) Successful in 2m38s
CI / perf (push) Successful in 4m33s
## Summary

Follow-up polish on the audit-log Charts tab shipped in #174. Three small but visible regressions surfaced once real data was loaded:

1. **"Events per day" bar chart cycled a categorical palette across the X axis** — every day got a different colour, which read as "categorical meaning per day" when the X axis is just a time bucket. Switched to a single fixed fill (curated blue from the lib palette).
2. **"Outcome breakdown" donut painted slices in Set2 order** — `success` came out teal, `denied` came out orange. With orange = "danger" in most readers' mental model, this is the wrong way around. Added a `colorMap` input on `<lib-donut-chart>` and a curated `semanticStatusColors` export so the audit page can map `success → green`, `denied → orange`, `failure → red`.
3. **Charts tab caused a body scrollbar + empty space below the footer.** Plot renders into a hidden `[hidden]` panel on first switch, so `canvas.clientWidth` is 0 and Plot falls back to its 600/720 default — the resulting fixed-width SVG was wider than the grid column, dragged horizontal overflow up the tree, and (because the grid item defaults to `min-width: auto`) wouldn't shrink. The shell layout broke. Fixed by constraining the chart envelope and the grid items so the SVG can scale down to the actual column width.

## What lands

### Bar chart — single fill, lib-owned default

- `Plot.barY` mark no longer encodes colour from `xKey`. The `fill` parameter is now a literal-string colour, applied uniformly to every bar.
- New `fillColor` input on `<lib-bar-chart>` for the rare case a consumer needs a different shade; defaults to `DEFAULT_BAR_FILL = '#1d4ed8'` (≈ Tailwind blue-700, picked for AA contrast against both light and dark surfaces, colour-blind-safe).
- Removed the now-unused `color: { type: 'ordinal', range: palette }` scale and the `colorScheme` input on the bar chart — the categorical palette only made sense when `fill: xKey` was the default behaviour.

### Donut chart — optional semantic mapping

- New `colorMap?: Readonly<Record<string, string>>` input on `<lib-donut-chart>`. When provided, slice fill resolves to `colorMap[category]` first, falling back to the categorical palette for any unmapped category. Lib still owns the palette per ADR-0023; the map only constrains *which colour the consumer picks for which category*, not what colours exist.
- New `semanticStatusColors` export from `shared-charts` — a curated 5-entry map (`success / warning / error / info / neutral`) tuned for AA contrast and deuteranopia/protanopia distinguishability. Consumers stay inside the lib's palette without authoring their own hex codes.
- The audit page now ships `outcomeColorMap = { success: green, failure: red, denied: orange }` and passes it to the donut. The donut's `description` (screen-reader fallback) and the legend chip semantics now line up.

### Chart envelope — overflow containment

`libs/shared/charts/src/lib/_internal/chart-envelope.scss`:

- Force `display: block` + `min-width: 0` on every chart host element (`lib-bar-chart`, `lib-donut-chart`, `lib-stacked-bar-chart`). Angular custom-element hosts default to `display: inline`, which lets the inner `<figure>` escape parent sizing constraints — the root cause of the body-scrollbar symptom.
- `.chart-canvas` gets `min-width: 0` and constrains every inner `figure { max-width: 100% }` + `svg { max-width: 100%; height: auto }`. The SVG keeps its viewBox aspect ratio while scaling down to the actual column width.

`apps/portal-admin/src/app/pages/audit/audit.scss`:

- `.chart-tile { min-width: 0; }` — grid items default to `min-width: auto`, which prevents shrinking below the intrinsic content width. Without it, Plot's fixed-width SVG could still push the grid column past `1fr` even with the lib-side fixes.

## Notes for the reviewer

- **Why a hardcoded hex (`#1d4ed8`) for `DEFAULT_BAR_FILL` rather than a brand token?** The chart lib is intentionally decoupled from `libs/shared/tokens` — it has no SCSS/CSS-vars dependency and Plot writes the fill as an SVG attribute, not a CSS value, so a CSS variable wouldn't apply anyway. The hex stays inside the lib, marked as the canonical default, with the docstring promising AA contrast + colour-blind safety. If the brand wants to take it over later, swap the constant in one place.
- **`semanticStatusColors` is a curated map, not an open extension point.** Five entries (`success / warning / error / info / neutral`) covers the audit module and any future status-bearing donut (user list, integrations health, etc.). Adding a sixth entry needs a small PR + an a11y-contrast check, which is the right friction.
- **The `aria-label="bar"` selector in the new bar-chart spec.** Plot tags its bar-mark `<g>` with `aria-label="bar"` (and similarly `"rule"` for `Plot.ruleY`). Selecting on it scopes the fill-uniqueness check to actual bars, not axes / ticks / labels. If Plot changes that label upstream the spec breaks loudly — preferable to a fragile geometric selector.
- **Lazy fetch policy and the empty-state edge case** from #174 are unchanged. The donut still receives `colorMap` even when `data` is empty; the lib's `arcs.forEach` short-circuits on zero arcs so no colour lookup happens.
- **`audit.scss` is now 7.5 KB**, still over the 6 KB component-style warning (untouched from #174) and well under the 8 KB error. The new `.chart-tile { min-width: 0 }` rule is two lines.

## Test plan

- [x] `pnpm nx test shared-charts` — **15 specs pass** (was 14: +1 donut `colorMap` test, +1 bar single-fill test, -1 obsolete bar palette assumption).
- [x] `pnpm nx test portal-admin` — **62 specs pass** (unchanged from #174 — semantic mapping is a template-only change, behavioural tests still hold).
- [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts` — clean. Same three pre-existing lint warnings, no new ones; bundle sizes unchanged within ±0.1 KB.
- [ ] **Manual smoke** — `pnpm nx serve portal-admin` + `pnpm nx serve portal-bff`, sign in with `Portal.Admin`, navigate to `/admin/audit`:
  - Switch to **Charts** tab — daily-volume bars all render the same blue.
  - Donut centre matches `s.total`, the green slice is `success`, the orange slice is `denied`, the red slice is `failure`. Hover each slice — `<title>` shows `<category>: <count>` (untouched a11y contract from the lib).
  - Body scrollbar stays absent; footer stays anchored at the viewport bottom with no white-space gap.
  - Resize the window through the `(max-width: 800px)` breakpoint — grid collapses to one column, charts re-flow without horizontal overflow.
  - Dark mode toggle — colours stay readable, no contrast regressions.

## What's next

Nothing in this chantier. The audit dashboard is now visually coherent and layout-stable. Two background items to track separately when the CMS / user-list pages land their own dashboards:

- Tab-state URL persistence (carried forward from #174's "what's next").
- Promote the WAI-ARIA tab pattern out of `audit.html` into `libs/shared/ui/tabs/` once a second consumer appears.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #175
2026-05-17 01:17:33 +02:00
julien eb8b65c7bc feat(shared-charts): foundations + bar / donut / stacked-bar components (#171)
CI / commits (push) Has been skipped
CI / check (push) Successful in 5m30s
CI / scan (push) Successful in 2m44s
CI / a11y (push) Successful in 2m37s
CI / perf (push) Successful in 4m21s
Docs site / build (push) Successful in 3m1s
## Summary

Implementation of [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) — the foundations of the workspace's chart library. PR 2 of the chantier:

| PR | Périmètre |
| --- | --- |
| PR 1  | ADR-0023 — decision + a11y contract + bundle plan. |
| **PR 2 (this one)** | `libs/shared/charts/` foundations + `<lib-bar-chart>`, `<lib-donut-chart>`, `<lib-stacked-bar-chart>`. |
| PR 3 | Integration on the `/audit` page — daily-volume bar + outcome-breakdown donut + event-type-over-time stacked bar. |

## What lands

### Workspace deps

```
d3                       — top-level toolkit, types via @types/d3
d3-shape                 — used directly by <lib-donut-chart>
d3-scale-chromatic       — colour-blind-safe palette source
@observablehq/plot       — declarative layer over D3, used by bar + stacked-bar
```

All four (+ matching `@types/*`) land in the workspace root `devDependencies`. Tree-shaken at build time per ADR-0023's bundle plan.

### New lib `libs/shared/charts/`

```
libs/shared/charts/src/lib/
├── _internal/                   ← single source of truth for the a11y contract
│   ├── a11y.ts                  ← chartId, findChartSvg, injectSvgTitleDesc, prefersReducedMotion, resolveTheme
│   ├── chart-envelope.scss      ← shared figure / caption / fallback / dark-mode rules
│   ├── chart-types.ts           ← `ChartBaseInputs<T>` extended by each component
│   └── palette.ts               ← Viridis / Cividis (sequential) + ColorBrewer Set2 (categorical)
├── bar-chart/                   ← Plot.barY
├── donut-chart/                 ← raw d3-shape (pie + arc); Plot has no donut mark
└── stacked-bar-chart/           ← Plot.barY with `fill: <seriesKey>` (auto-stacked, legend on)
```

### A11y contract baked in for v1

Per ADR-0023's six commitments, every chart component produces (and unit-tests for):

1. `<figure role="img" aria-labelledby aria-describedby>` wrapping the SVG.
2. SVG `<title>` + `<desc>` as the **first two children** — injected post-render via `injectSvgTitleDesc` because Plot doesn't emit them itself. `findChartSvg` handles both Plot output shapes (bare SVG, or `<figure>` wrapping a legend + SVG for `legend: true` configs).
3. A `<details>` disclosure with a `<table>` rendering every data point — the keyboard-navigable / screen-reader-friendly fallback for non-visual users.
4. Palette from `_internal/palette.ts` only — Viridis / Cividis for sequential, ColorBrewer Set2 for categorical. Both colour-blind-safe.
5. AA-contrast axis text via `:where(.dark)` flips in `_internal/chart-envelope.scss`.
6. `prefers-reduced-motion` → `data-no-transitions` marker on the SVG, CSS strips animations + transitions.

A custom ESLint rule in [`libs/shared/charts/eslint.config.mjs`](libs/shared/charts/eslint.config.mjs) bans direct imports of `d3-scale-chromatic` outside `_internal/palette.ts` so a future contributor can't bypass the colour-blind-safe contract.

### Component contract

Every `<lib-*-chart>` exposes the same Signal-based shape per ADR-0023:

```ts
[data]: readonly T[];
[caption]: string;
[description]: string;
[ariaLabel]: string;
[colorScheme]?: 'sequential' | 'categorical';
// + chart-specific keys (xKey, yKey, categoryKey, valueKey, seriesKey, …)
```

Re-renders triggered by Angular's `effect()` on input changes; the previous SVG is `replaceChildren`-d out so there's no DOM accumulation across data updates.

## Notes for the reviewer

- **Why three SCSS files importing one shared envelope?** Extracted at the third consumer per CLAUDE.md's "three similar lines is better than a premature abstraction" rule — bar + donut + stacked-bar share ~70 LOC of figure / caption / fallback / dark-mode chrome. `_internal/chart-envelope.scss` is the consolidation; each chart's `.scss` is now 4-20 LOC of chart-specific tweaks.
- **Why is `<lib-donut-chart>` raw D3 rather than Plot?** Plot's design philosophy explicitly excludes pie/donut marks ("a bar chart is almost always more legible"). The audit-log outcome breakdown reads naturally as a donut (the centre carries the total). Raw `d3-shape` is the lower-level fallback ADR-0023 reserves precisely for this kind of case; the component's API is identical to the Plot-backed siblings.
- **Why does the donut also stamp per-slice `<title>`?** Belt-and-suspenders. The top-level SVG `<title>` reads the caption; per-slice `<title>` reads "category: value" on hover (the SVG-native tooltip convention) for keyboard / screen-reader users who land on a specific slice.
- **Why no `pnpm.overrides` adjustment for `d3-*` transitives?** None of the new deps brought a vulnerability in this install. The `pnpm audit --audit-level=moderate` gate from #161 stays green.
- **What's deliberately deferred to PR 3?** The `/audit`-page integration — data aggregation from the current page (`AdminAuditPage` rows already loaded), the actual `<lib-*-chart>` placements above the existing table, the i18n strings for the captions / descriptions / aria-labels. No mock SAMPLE data shipped in this PR — every test uses local fixtures so the lib stays decoupled from any specific consumer.

## Test plan

- [x] `pnpm nx test shared-charts` — **13 specs pass** across the three components.
- [x] `pnpm nx lint shared-charts` — clean, including the custom `no-restricted-imports` guard on `d3-scale-chromatic`.
- [x] `pnpm nx build shared-charts` — clean (TS strict + ng-packagr).
- [x] `pnpm nx run-many -t lint test build --projects=shared-charts,portal-shell,portal-admin` — 12/12 tasks green.
- [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build clean. The lib ships no i18n marks (axis labels are caller-supplied per ADR-0023's i18n posture), so no xlf entry change here.
- [ ] **Visual smoke (deferred to PR 3 when the components are placed on `/audit`)** — `<figure>` + caption visible, SVG `<title>` exposed by VoiceOver / NVDA, `<details>` fallback expands to a table, dark-mode toggle flips axis text + Cividis palette, `prefers-reduced-motion` strips Plot's fade-in.

## What's next

PR 3 wires the three components onto the `/audit` page: aggregates `AdminAuditPage.items` client-side into the three required shapes (daily totals, outcome counts, daily-by-event-type pivots), places them above the existing filter form, ships the i18n strings for the captions and descriptions in `messages.fr.xlf`. Bundle impact on the lazy `audit` chunk gets verified there (the ~65 KB gzip plan from the ADR).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #171
2026-05-16 21:56:28 +02:00