Commit Graph

6 Commits

Author SHA1 Message Date
julien 7ee7b2dadf docs(adr-0023): charts and dashboards — d3 + observable plot (#170)
CI / check (push) Successful in 1m17s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m2s
CI / a11y (push) Successful in 2m33s
CI / perf (push) Successful in 6m7s
Docs site / build (push) Successful in 3m37s
## Summary

Records the decision to use **D3 + Observable Plot**, wrapped in a new `libs/shared/charts/`, as the chart toolkit shared by `portal-shell` and `portal-admin`. ADR-only — implementation lands as the next chantier(s).

This is staged as a 3-PR chantier per the agreed plan:

| PR | Périmètre |
| --- | --- |
| **PR 1 (this one)** | ADR-0023 — decision + a11y contract + bundle plan. |
| PR 2 | `libs/shared/charts/` foundations + 3 starter components (`<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

### [`docs/decisions/0023-charts-d3-observable-plot.md`](docs/decisions/0023-charts-d3-observable-plot.md)

Full MADR 4.0.0 record. Highlights:

- **Choice**: D3 + Observable Plot, both from Mike Bostock / Observable Inc., both MIT, both past 1.0. Plot covers ~80 % of standard charts in declarative one-liners; D3 stays the escape hatch for bespoke viz (heatmap, sankey, …) inside the same lib.
- **Why not D3 alone**: ~250 LOC per chart × 4-5 types × a11y discipline = sustained code investment before the first dashboard ships.
- **Why not ECharts / Chart.js**: 600 KB minified + canvas-rendered + an `aria` plugin afterthought (ECharts), or narrower vocabulary + brittle dark-mode (Chart.js). Both furthest from the Angular-Signals-zoneless idiom the rest of the workspace runs on.
- **A11y contract** is baked into `_internal/` (palette, tabular fallback, SVG `<title>` / `<desc>` builders) so every chart inherits WCAG 2.2 AA + AAA-targeted compliance from the lib, not from contributor discipline. Six commitments, each unit-tested per chart component.
- **Bundle plan**: ~65 KB gzip added to a chart-bearing lazy chunk (d3 modules tree-shaken + Plot + thin wrapper) — well under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s 100 KB cap.
- **Component contract**: every `<lib-*-chart>` exposes the same Signal-based input shape (`[data]`, `[caption]`, `[description]`, `[ariaLabel]`, `[colorScheme]`) regardless of whether Plot or raw D3 powers the rendering.

### [`docs/decisions/README.md`](docs/decisions/README.md)

ADR-0023 added to the index table.

### [`CLAUDE.md`](CLAUDE.md)

- "Architecture (recorded in ADRs)" gains a "Charts + dashboards" bullet describing the lib + a11y baseline + bundle posture.
- "Repository status" bumps the ADR range to `0001 → 0023`.
- "Still on the roadmap" gains the charts implementation entry pointing at this ADR.

## Notes for the reviewer

- **Why honour the user's D3 preference rather than recommend pure ECharts?** D3 (and by extension Plot) is the closest match to the project's tech bar ("stable, recognized, battle-tested") for data-viz on the web; it's also the user's stated preference, and Plot's higher-level layer eliminates the "250 LOC per chart" cost that would otherwise push us toward an alternative. The ADR explicitly walks through ECharts + Chart.js as runners-up so future challengers see the trade-offs we chose against.
- **Why a single shared lib rather than per-app charts?** Both SPAs (portal-shell + portal-admin) will host dashboards. The chart vocabulary, a11y contract, palette, and theme integration are identical between the two — duplicating into app-local code would invite drift. The lib stays at `libs/shared/charts/` next to `libs/shared/ui/`.
- **Why the `_internal/` folder for cross-cutting code?** Single source of truth for the colour palette and the a11y plumbing. A lint rule (added in PR 2) will ban consumers from importing `d3-scale-chromatic` directly so the colour-blind-safe palette stays the only path.
- **Why no ADR amendment to ADR-0016 / ADR-0017?** Both are binding constraints, not superseded. The new ADR operationalises both for the chart surface; cross-references in the "Related ADRs" section make that explicit.

## Test plan

- [x] ADR validates as MADR 4.0.0 (frontmatter, section order, tag vocabulary).
- [x] No code touched — lint / test / build matrix unaffected.
- [x] `docs/decisions/README.md` index updated in the same change per the [ADR conventions](docs/decisions/README.md#conventions).
- [ ] Review for trade-off accuracy: are the bundle estimates fair? Is the "Plot covers ~80 % of standard charts" framing defensible against the user's mental model of D3?
- [ ] Implementation chantier (PR 2) lands directly behind this if accepted: `pnpm add -w d3 @observablehq/plot @types/d3`, `libs/shared/charts/` scaffold via `pnpm nx g @nx/angular:library --name=shared-charts --directory=libs/shared/charts --standalone=true --unitTestRunner=vitest-analog --tags="scope:shared,type:shared" --no-interactive`, then the 3 starter components.

## What's next

If accepted as-is, PR 2 (lib foundations + 3 starter components) follows. If a reviewer wants to push back on D3-vs-ECharts or on the a11y contract's strictness, this is the right PR to surface that — no implementation has started.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #170
2026-05-16 21:28:03 +02:00
julien aa8ad97feb fix(portal-admin): adr refs point at gitea, not the madr template repo (#153)
CI / check (push) Successful in 1m47s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m48s
CI / a11y (push) Successful in 2m1s
CI / perf (push) Successful in 3m33s
## Summary

Records the decision to render `docs/**/*.md` as a separately-deployed static site using **VitePress + `vitepress-plugin-mermaid`**.

This is **ADR-only** — the implementation (install + `.vitepress/config.ts` + `docs/index.md` + Gitea Actions workflow) lands as the next chantier. Splitting them keeps the decision review focused on the *why* before the *how*.

## What lands

### [`docs/decisions/0022-docs-site-vitepress.md`](docs/decisions/0022-docs-site-vitepress.md)

Full MADR 4.0.0 ADR. Decision drivers, 4 considered options (VitePress, MkDocs Material, Docusaurus 3, Astro Starlight), site-structure mapping, Mermaid integration, deployment + CI plan, consequences, revisit triggers. Tags: `process`, `infrastructure`.

**Key choices captured:**

- VitePress wins on toolchain alignment (Vite already in the workspace via `@nx/vite`, `vite-plugin-angular`, Vitest). MkDocs Material was the strong runner-up; the Python runtime tax in Gitea Actions tipped the balance.
- `vitepress-plugin-mermaid` for ```` ```mermaid ```` blocks ([ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) sequence + [architecture.md](docs/architecture.md) C4 are hard requirements).
- Site sources entirely from `docs/`. Mapping:
  - `docs/index.md` (new, Hero layout) → `/`
  - `docs/development.md` → `/development`
  - `docs/architecture.md` → `/architecture`
  - `docs/decisions/README.md` → `/decisions/` (curated index, kept as section landing)
  - `docs/decisions/00NN-….md` → auto-listed in the sidebar by numeric prefix
  - `docs/setup/0N-….md` → `/setup/0N-…`
- **Excluded** via `srcExclude`: `docs/README.md` (stays as the git-side / IDE-preview index — option A from the prior discussion) and `docs/decisions/template.md` (authoring scaffold).
- Empty placeholder sections in `docs/README.md` (Operations runbooks, Security/perf/a11y rationales) are NOT pre-created as empty pages — they appear in the sidebar when real content lands.
- Deployment: dedicated hostname (provisional `docs.portal.apf.fr`) behind the same Caddy reverse-proxy as the apps, fed by a new `.gitea/workflows/docs-site.yml` triggered on `docs/**` push. Exact hostname follows the future infrastructure ADR; not locked here.

### [`docs/decisions/README.md`](docs/decisions/README.md)

ADR-0022 added to the index table.

### [`CLAUDE.md`](CLAUDE.md)

Bumps the ADR range from `0001 → 0021` to `0001 → 0022`. New bullet in the "Architecture (recorded in ADRs)" section describing the docs-site choice in one paragraph. Implementation tracked in "Still on the roadmap" until the next PR lands it.

## Notes for the reviewer

- **Why ADR before implementation?** The choice between VitePress / MkDocs Material / Docusaurus / Astro Starlight is exactly the "stable + recognized + innovative" trade-off [CLAUDE.md](CLAUDE.md) asks to document. Reviewing the rationale on its own (without dragging through the install diff) keeps the discussion focused.
- **Why not surface ADRs inside `portal-admin`?** Audience mismatch — [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"Audience is disjoint" frames `portal-admin` around APF internal operators (CMS, audit, user directory), not architects. The full reasoning is in ADR-0022 §"Context and Problem Statement".
- **Why two index artefacts (`docs/README.md` + future `docs/index.md`)?** Option A from the structure discussion. Each serves a distinct audience: `README.md` is the flat link list that renders well in IDEs / Gitea source view; `index.md` will be the VitePress Hero landing for the web audience. Light duplication, no maintenance pressure (the IDE one only needs updating when sections appear/disappear).
- **Why `vitepress-plugin-mermaid` rather than Docusaurus's built-in Mermaid?** The community plugin is a sub-1.0 dependency on the wrapper (Mermaid itself is mature), but Mermaid is so mainstream that switching it out is a half-day rewrite if the plugin stalls. Trading that risk against Docusaurus's MDX-by-default footprint + React runtime is a net win.
- **Why `process` + `infrastructure` tags?** Mirrors [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md) (also a CI / deploy decision with content authoring implications) and is consistent with the [tag vocabulary](docs/decisions/README.md#tag-vocabulary). No new tag invented.

## Test plan

- [x] `docs/decisions/0022-docs-site-vitepress.md` validates as MADR 4.0.0 (frontmatter, section order). Index in [`docs/decisions/README.md`](docs/decisions/README.md) updated in the same PR per [ADR conventions](docs/decisions/README.md#conventions).
- [x] No code touched — `lint / test / build` matrix unaffected.
- [ ] Review for trade-off accuracy: did I get MkDocs Material's strengths right? Is Astro Starlight's maturity argument fair?
- [ ] Implementation chantier (next PR): `pnpm add -D vitepress vitepress-plugin-mermaid mermaid`, `docs/.vitepress/config.ts`, `docs/index.md`, `.gitea/workflows/docs-site.yml`, `package.json` scripts. Will land within the same week assuming this ADR holds.

## What's next

If accepted as-is, the immediate follow-up is:

1. Install VitePress + the Mermaid plugin.
2. Author `docs/.vitepress/config.ts` with the sidebar shape spelled out in this ADR (auto-generated sub-sidebar for `/decisions`, hand-curated top-level).
3. Author `docs/index.md` (Hero layout).
4. Add the `docs-site` Gitea Actions workflow.
5. Wire the dev script (`pnpm docs:dev`) into `package.json` so contributors can preview locally.

If reviewers want to push back on the toolchain choice (MkDocs Material in particular has a strong case for the theme polish), this is the right PR to surface that — implementation hasn't started.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #153
2026-05-15 18:48:07 +02:00
julien da2bd6d481 docs(adr-0021): phase-2 security baseline (helmet, CORS, CSRF, rate-limit, error envelope) (#124)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m12s
CI / check (push) Successful in 2m22s
CI / a11y (push) Successful in 49s
CI / perf (push) Successful in 3m48s
## Summary

Documents the security middleware stack shipped across the five most recent BFF PRs (#115, #117, #120, #122, #123) as a single MADR ADR. Today the rationale for each choice lives in code comments and PR descriptions; the next contributor reaching for `csurf`, a cookie-only CSRF, or a hardcoded localhost CORS fallback won't have a single place to read why those are wrong here.

ADR-0021 covers:

- **Response envelope** — `{ error: { code, message, traceId } }`, single contract shared between Nest's `StructuredErrorFilter` and raw Express middlewares (CSRF, rate-limit) via the exported `errorResponse()` helper. Status code → code mapping documented. 500s never leak the underlying exception.
- **CSRF — session-bound double-submit**, not pure cookie-vs-header. Rationale: a subdomain-takeover cookie injection can't bypass the comparison because the source of truth is the server-side session token, not the cookie. Cookie is the SPA's read-only mirror.
- **CORS allowlist** — `CORS_ALLOWED_ORIGINS` mandatory at boot, no fallback. "Works in dev, breaks in prod" is the exact trap the validator catches.
- **Rate limiting** — buckets keyed by session id (auth) or IP (anonymous), 10/min on `/auth/login` + `/auth/callback`, 120/min general, `/api/health` skipped. In-memory v1 store; Redis-backed migration is one constructor arg.
- **Helmet config** — defaults plus three overrides (HSTS prod-only, `crossOriginResourcePolicy: cross-origin`, CSP prod-only). Each override has a code-anchored justification.

## Scope

This is the **implementation-level** security ADR. The **strategic** security baseline ADR — OWASP ASVS reference level, HDS / GDPR / NIS 2 framing, RSSI sign-off — remains paused per the note in [CLAUDE.md](CLAUDE.md). When that ADR lands it'll either confirm 0021 or supersede pieces of it; 0021 is explicit about which choices are tactical and revisitable.

## Notable structural choices in the ADR

- **"Considered Options" mirrors the actual debate.** For CSRF: session-bound vs pure double-submit vs `csurf` vs synchronizer. For rate-limit bucket key: sessionID-then-IP vs IP-only vs Entra `oid`. Each rejected option has its "Bad, because …" so the reader sees why we didn't go that way.
- **Each "Decision Outcome" line points at the file / function that enforces it.** Cross-references are absolute paths so they survive folder reorgs.
- **The "Consequences" section is brutally honest about trade-offs.** In-memory rate-limit doesn't scale horizontally. The CSRF cookie is XSS-readable. No `details` field in the envelope for field-level validation errors yet. These aren't hidden in the prose.

## Other doc touches

- [docs/decisions/README.md](docs/decisions/README.md) index entry added.
- [notes/handoff.md](notes/handoff.md) refreshed (gitignored; not part of the commit but useful for the next session).

## Noted for a separate PR (out of scope here)

[CLAUDE.md](CLAUDE.md) §"Repository status" still says "The Nx workspace is **not yet bootstrapped**" and refs ADRs up to 0020. The whole paragraph is stale (the project is fully scaffolded, 22 ADRs in place). Worth a small dedicated docs PR.

## Test plan

- [x] `pnpm exec prettier --check docs/decisions/` → clean.
- [x] ADR follows the MADR 4.0.0 template (frontmatter with `status`, `date`, `decision-makers`, `tags`; sections in the canonical order).
- [x] Tags drawn from the vocabulary in [docs/decisions/README.md](docs/decisions/README.md#tag-vocabulary): `security`, `backend`.
- [x] Index in `docs/decisions/README.md` updated in the same change.
- [x] Cross-references to ADRs 0009, 0010, 0012, 0015 verified.
- [ ] Renders in Gitea / IDE markdown preview without parser warnings.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #124
2026-05-13 23:54:45 +02:00
julien c5e91f240b docs(decisions): add ADR-0019 i18n + ADR-0020 portal-admin (#89)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m22s
CI / check (push) Successful in 2m36s
CI / a11y (push) Successful in 53s
CI / perf (push) Successful in 3m25s
## Summary

Pure documentation PR. Two ADRs that answer the two strategic questions raised after the footer chantier:

- **[ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md)** — how the portal handles multiple languages.
- **[ADR-0020](docs/decisions/0020-portal-admin-app.md)** — where portal administration lives.

Implementation will land across follow-up feature PRs, each consumable on its own.

## ADR-0019 — Internationalisation

**Decision:** `@angular/localize` in **build-time** mode, two locales (`fr` default served at `/`, `en` source). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. The locale switcher in the footer writes a `__Host-portal_locale` cookie and hard-refreshes to the matching bundle.

**Considered and rejected:**

- `@angular/localize` runtime mode (single bundle, higher LCP / payload cost).
- `@ngx-translate` / `transloco` (community libraries; tech-bar prefers Angular first-party for foundational primitives).
- Query-param URL strategy (fragile, weaker SEO, `<html lang>` becomes harder).
- Subdomain URL strategy (breaks `__Host-` cookie scoping from ADR-0010).

**Scope boundary:** UI strings owned by developers (templates + `$localize` in code). Editorial content (CMS-managed pages, news, etc.) is BFF-served already localised — that's the admin-app pipeline (ADR-0020), not `@angular/localize`.

**First sweep consequence:** the duplicate `/accessibility` + `/accessibilite` routes collapse to one Angular route with locale-translated paths.

## ADR-0020 — `portal-admin`

**Decision:** new Angular SPA `portal-admin` alongside `portal-shell`, sharing the existing `portal-bff` via `/api/admin/*` routes guarded by an Entra `admin` role plus `@RequireMfa({ freshness: 600 })` at the entry route. Distinct origin + cookie + session (`__Host-portal_admin_session`).

**v1 modules** (all four selected):

1. Editorial pages CMS (multilingual content, fed back to `portal-shell` via the BFF).
2. Sidebar menu management (activates the `requiredPermissions` field already on `MenuItem`).
3. User list (read-only).
4. Audit log viewer (consumes the `audit.events` table per ADR-0013, via the `audit_reader` role).

**Out of v1:** B2B invitations (stay in Entra Admin Center), feature flags (no substrate yet), CMS workflow / approval flows, theme customisation, live preview.

**Considered and rejected:**

- `/admin/*` lazy-loaded inside `portal-shell` (admin code in the same origin → weaker defense in depth, admin URL not IP-restrictable independently).
- Two SPAs **and** two BFFs (doubles infra at our scale — bricolage).
- Off-the-shelf admin tooling (Retool, etc. — escapes our security baseline).

**Performance budget for admin:** ≤ 500 KB gzip initial (vs 300 KB for `portal-shell`, per ADR-0017). Lighthouse Performance ≥ 85 on critical admin routes (vs ≥ 90 on `portal-shell`). Same a11y baseline (ADR-0016), same dark-mode support.

**Shared-libs graduation:** `Icon`, `LayoutStateService`, brand tokens, dark-mode SCSS helpers move from `portal-shell` to `libs/shared/{ui,state}` when both apps need them. Mechanical refactor; tracked as the first implementation PR.

## Implementation roadmap (out of scope of this PR)

ADR-0019:

1. Install `@angular/localize`, wire build target.
2. Mark every existing UI string in `portal-shell` with `i18n` + `@@id`; produce `messages.fr.xlf`.
3. Locale switcher in footer + `/api/preferences/locale` BFF route + smart redirect at `/`.
4. Collapse the duplicate accessibility routes into a localised single route, with 301s.
5. CI gate: `nx build portal-shell --localize` is added to `ci:check` and fails on missing translation.

ADR-0020:

1. `nx g @nx/angular:app portal-admin` skeleton.
2. Shared-libs extraction (`libs/shared/ui`, `libs/shared/state`).
3. BFF `AdminModule` + `AdminRoleGuard` + smoke `GET /api/admin/me`.
4. Admin shell (header / sidebar / footer with an "Admin" badge).
5. One PR per v1 module — suggested order: CMS pages → menu management → audit viewer → user list.

## Test plan

- [x] Both ADRs follow MADR 4.0.0 (frontmatter, sections, tags from the canonical vocabulary).
- [x] `docs/decisions/README.md` index updated in the same commit.
- [x] `CLAUDE.md` architecture summary picks up entries for both decisions and bumps the ADR coverage line to 0020.
- [ ] Read-through review — invite the project lead to push back on any decision before locking implementation.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #89
2026-05-11 12:29:54 +02:00
julien 4476cbb518 docs(decisions): add ADR-0018 — environment configuration strategy (#80)
CI / check (push) Successful in 1m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m2s
CI / a11y (push) Successful in 1m36s
CI / perf (push) Successful in 3m33s
## Summary

Records the per-environment config strategy for both apps. ADR-only — no code changes; the implementation is anchored in §Confirmation against the next feature work that touches per-environment values.

## What lands

[**ADR-0018 — Environment configuration**](docs/decisions/0018-environment-configuration-strategy.md):

- **SPA**: Angular `environment.ts` + project.json `fileReplacements`. Build-time substitution; no runtime config fetch (rejected for the LCP/TTFB cost it would add and the deploy-time HTML rewrite that the alternatives need). Concretely cleans up the hard-coded URLs we left in `observability/tracing.ts` and `home-status.service.ts` ("hard-coded for v1 — env-config when it lands" comments).
- **BFF**: keep `process.env` + small per-key boot-time validators (the shape `apps/portal-bff/src/config/check-database-url.ts` already follows). `@nestjs/config` rejected as too heavy for the current key count; `zod` not justified yet.
- **Audit log connection**: formalises the `AUDIT_DATABASE_URL` split that ADR-0013 §"Wired as features land" already pointed at. When set, the `AuditModule` instantiates a second Prisma client with `audit_writer`-only credentials (defense in depth); when unset, the dev fallback (shared pool + `SET LOCAL ROLE audit_writer` per transaction) keeps working. A boot-time `UPDATE`-rejection self-test runs against the dedicated pool when configured — refuses to start if the pool can mutate the audit table.

The §Confirmation block cross-references the env-var-as-boot-gate items already pre-figured in ADR-0009 / ADR-0010 / ADR-0012 / ADR-0013 / ADR-0014, so the validator pattern is the single landing place when those features ship.

## What does NOT change in this PR

- No `apps/portal-shell/src/environments/*.ts` files yet — landed alongside the next feature that actually needs per-environment values.
- No `AUDIT_DATABASE_URL` validator in BFF — same; lands when the second pool is wired.
- No CLAUDE.md restructuring; just one extra bullet under the Architecture summary referencing ADR-0018.

## Doc updates

- `docs/decisions/README.md` index — new row for ADR-0018.
- `CLAUDE.md` Architecture summary — one-line reference to ADR-0018 between the perf-budget and local-quality-gates entries.

## Test plan

- [ ] CI green on this PR (`format:check`).
- [ ] ADR-0018 renders in the doc index with the right tags (`frontend`, `backend`, `infrastructure`, `process`) and 2026-05-10 date.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #80
2026-05-10 04:58:02 +02:00
Julien Gautier 0e58e32d29 chore: relocate ADRs from decisions/ to docs/decisions/ to consolidate documentation
Move the ADR folder under docs/ alongside the rest of the project
documentation. Convention (flat folder, globally-sequential 4-digit
numbering, tags-based categorization, MADR 4.0.0 format) is unchanged
- only the path moved.

- git mv decisions docs/decisions preserves history for all 18 ADRs +
  README + template (19 files renamed in this commit).
- ADR-0001 amended in-place with a dated note documenting the
  relocation. Status remains 'accepted' - the location detail
  changed, the decision did not.
- All cross-references updated:
  - CLAUDE.md (~17 ADR links + 3 mentions of decisions/ in the Project
    rules section)
  - docs/README.md (now references decisions/ as a sibling under docs/)
  - docs/setup/03-angular-nx-monorepo.md (paths shortened from
    ../../decisions/ to ../decisions/, since setup/ and decisions/ are
    now both inside docs/)
  - docs/decisions/0003 ../CLAUDE.md adjusted to ../../CLAUDE.md
    (one extra level of nesting)
  - docs/decisions/template.md mention of the README path
  - notes/asvs-level-decision-briefing-rssi.md mention of the index

Sanity verified: every ADR link in CLAUDE.md, docs/setup/03, and
docs/decisions/0001 resolves to an existing file. pnpm nx run-many
-t lint passes on 8 projects.
2026-04-30 18:57:59 +02:00