Files
atomic-design-poc/README.md
Edwin van den Houdt e276629107 feat(fp): WP-20 — second locale proof (nl/en build seam)
angular.json gains an i18n block (sourceLocale nl, en translation file) and
an `en` build/serve configuration with i18nMissingTranslation: "error" so a
new $localize string without an English unit fails the build, not silently
falls back. CI now runs `ng build --localize` to build both locales every
run. Verified end-to-end, not just "the build succeeded": the nl bundle
ships "Inloggen met DigiD", the en bundle ships "Log in with DigiD".

Incidental: prettier/compodoc regen noise in docs/wcag-checklist.md,
src/docs/a11y.mdx, documentation.json from the same working session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:16:11 +02:00

203 lines
12 KiB
Markdown

# BIG-register Self-Service Portal — Atomic Design POC
A small Angular app that shows how **atomic design** makes a frontend cheap to build,
reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch
register of healthcare professionals, run by CIBG). It is styled with the **CIBG
Huisstijl** design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003),
and demonstrates a robust **async-state pattern** where the UI can never reach an
inconsistent state.
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
> business rules and data _are_ served by a real **ASP.NET Core backend**
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed
> Rijksoverheid font and a text wordmark for the logo.
---
## Run it
```bash
docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
```
Or run the two halves separately:
```bash
npm install
npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
# in another terminal:
cd backend && dotnet run --project src/BigRegister.Api # API → http://localhost:5000/swagger
npm run storybook # component library, organized by atomic layer
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
```
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
The backend hosts the business rules (profession derivation, policy questions,
eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions
> appear based on earlier answers and progress survives a page reload, plus a visual
> walkthrough of the state-management ideas. See
> **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** for diagrams (atomic-design pyramid,
> the dispatch→reduce→view loop, RemoteData states, and "why not just signals") and a
> section on **connecting to a .NET backend**.
### See every data state (scenario toggle)
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
| URL | What you see |
| ----------------------------- | -------------------------------------------- |
| `/dashboard` | real data (fast) |
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
| `/dashboard?scenario=loading` | the loading state, held open |
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
---
## How atomic design works here (folder = layer)
Atomic design organizes UI into five layers, each built from the one below. In this repo
the folder structure _is_ the hierarchy (`src/app/`):
| Layer | What it is | Examples here |
| -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
Each atom is a thin Angular standalone component that applies CIBG Huisstijl
(Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system
does the visual work and we only own a small, typed component API.
---
## Where you actually notice the benefit
**1. Reuse — the same blocks appear everywhere.**
| Component | Appears in |
| ----------------------------- | ------------------------------------------------------------- |
| `button` | login, change-request, herregistratie, async retry, Storybook |
| `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
| `status-badge` | dashboard summary, detail summary |
| `page-shell` / `page-layout` | all four pages |
| `site-header` / `site-footer` | every page |
| `async` + `skeleton` | dashboard, detail |
Change a component once and every screen that uses it updates.
**2. A whole new page = composition, no new components.**
`pages/herregistratie/herregistratie.page.ts` is a complete new flow assembled entirely
from existing atoms/molecules/templates — zero new building blocks. The branching
**intake wizard** went further: it needed only **one new atom** (`radio-group`) and **one
new organism** (`intake-wizard`); the form fields, buttons, alerts, spinner and page shell
were all reused. That's the payoff: new screens cost almost nothing.
**3. Templates remove per-page boilerplate.**
Every page used to repeat its own back-link + heading + intro markup. `page-shell`
captures that once; pages now read like `<app-page-shell heading="…" backLink="…">…`.
**4. Theming is one stylesheet + a token bridge.**
The look comes from **CIBG Huisstijl**, vendored under `public/cibg-huisstijl/` and
loaded via a `<link>` in `index.html`; `body.brand--cibg` activates CIBG's
robijn/lintblauw palette. `src/styles.scss` is a **token bridge** mapping the app's
semantic `--rhc-*` token vocabulary onto CIBG/`--bs-*` values, so components keep
referencing tokens — swap the vendored CSS and re-point the bridge to re-theme the
whole app, no component changes (ADR-0003). `npm run check:tokens` fails the build
on any hardcoded colour outside that bridge.
---
## State management (no impossible states)
Data fetching uses Angular's native, signal-based **`resource`** over the generated
typed client (no NgRx, no extra dependency). Each context's `infrastructure/*.adapter.ts`
exposes a resource that carries `status()`, `value()`, `error()` and `reload()` as
signals, and a `parse*` function validates the response at the trust boundary
(DTO → domain). The screen-shaped ("BFF-lite") endpoints return server-computed
decisions the FE renders rather than recomputes (see ADR-0001).
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
mutually exclusive _by construction_. You cannot render data and an error at the same
time, or show stale content during a hard failure: those states are unrepresentable.
```html
<app-async [resource]="reg" [isEmpty]="regEmpty">
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
</app-async>
```
- **Loaded** — your content, with the value.
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
are also delay-gated. → _handles slow vs fast connections._
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
`isEmpty` predicate).
- **Error** — your template, or a default alert + a **retry** button that calls
`resource.reload()`.
Because each data-fetching page wraps its content in `<app-async>`, correct
loading/empty/error handling is automatic and consistent across the app.
---
## Page transitions
The chrome (`templates/shell` — header + footer) is **persistent**: it mounts once and
hosts the `<router-outlet>`, so navigating doesn't re-create it (no white flash). Only
the routed content cross-fades, via Angular's native **`withViewTransitions()`** — the
header/footer get a stable `view-transition-name` in `styles.scss` so they're excluded
from the fade. `prefers-reduced-motion` disables the animation; non-Chromium browsers
degrade to an instant navigation.
## Tech notes
- Angular 22 (standalone components, signals, `httpResource`, view transitions,
control flow `@if/@for`).
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
**dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified).
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
`en` — a genuine second-locale build, not just an unexercised claim — into
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
English, not machine-untranslated placeholders; `angular.json`'s
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
### Dependency security
The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`). All advisories
live in dev/build tooling (Storybook + the Angular build chain) and never reach the
bundle. `package.json` `overrides` pin patched transitive versions, taking the full
audit from 16 (incl. 3 high) down to **5 low** — the remainder all cascade from
`@babel/core`'s low-severity sourceMappingURL issue, which only "fixes" by jumping to
Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately left.
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC)
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, NgRx,
licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend
itself _is_ implemented.) i18n's build seam is proven (see above) but
production-quality translation, a runtime locale switcher, and RTL/pluralization
edge cases are not — the `en` file is demo-quality, and locale is a build-time
choice, not a switch in the running app.