Make AsyncLoadedDirective generic with a static ngTemplateContextGuard for AsyncComponent's own internal typing. That can't propagate to consumer `<ng-template appAsyncLoaded let-p>` sites though -- Angular only infers a structural directive's type parameter from an input bound on that same node, not from a sibling input on the parent component -- so the ~9 root-cause consumers (dashboard, registration-detail, aanvraag-detail, registratie-wizard) instead unwrap the RemoteData Success value via a typed computed() and narrow it locally with `@if (x(); as p)`. The remaining union-narrowing casts (registration-summary, showcase concepts page) are replaced with a stable @let binding and a direct resource read, respectively. Documented as a deviation in WP-06's backlog file.
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
docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
Or run the two halves separately:
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
Flow: Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake. The backend hosts the business rules (profession derivation, policy questions, eligibility, thresholds); see 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 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.
<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
isEmptypredicate). - 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.scssholds 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. .npmrcsetslegacy-peer-deps=truebecause@storybook/angular's peer range lags Angular 22; the builder runs fine (build verified).
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, i18n, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend itself is implemented.)