Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs
Checkpoint of in-progress work: the registration wizard (address prefill, DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't- validate value objects, infrastructure adapters, plus CLAUDE.md and the architecture/ADR docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
122
docs/architecture/0001-bff-lite-decision-dtos.md
Normal file
122
docs/architecture/0001-bff-lite-decision-dtos.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# ADR 0001 — Frontend⇄backend: BFF-lite endpoints + decision DTOs
|
||||
|
||||
Status: Accepted · Date: 2026-06-26
|
||||
|
||||
## Problem
|
||||
|
||||
The frontend makes many separate calls and aggregates them itself, and business
|
||||
rules are hardwired in the client. Two concrete symptoms:
|
||||
|
||||
- The dashboard stitched **three** independent `httpResource`s (BIG-register
|
||||
registration, BRP person, notes) together client-side. Each could be
|
||||
loading/erroring independently → inconsistent snapshots ("state out of sync").
|
||||
- Policy was duplicated on the client: the scholing threshold (`1000`) and the
|
||||
herregistratie eligibility window (`12` months) lived in frontend code. If the
|
||||
backend changes a rule, the UI silently diverges — bad for governance.
|
||||
|
||||
Goal: **unify FE/BE policy, cut the number of calls, and make the rules
|
||||
transparent/auditable — without coupling the two sides too tightly.** We own the
|
||||
backend team.
|
||||
|
||||
## Options considered
|
||||
|
||||
| Option | Fewer calls? | Unifies policy? | Cost |
|
||||
|---|---|---|---|
|
||||
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
|
||||
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
|
||||
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Low–medium |
|
||||
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
|
||||
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Medium–high; new infra |
|
||||
|
||||
GraphQL solves over/under-fetching but does not, on its own, move rules
|
||||
server-side — and our problem is policy unification + drift, not field-selection
|
||||
flexibility. Option 4 is option 3 with a deployment boundary added.
|
||||
|
||||
## Decision
|
||||
|
||||
**Screen-shaped ("BFF-lite") endpoints that return decision-enriched DTOs, defined
|
||||
by a single shared contract. The frontend renders decisions; it does not recompute
|
||||
them.** Keep it minimal: implement BFF-shaped endpoints on the backend we already
|
||||
own. Promote to a separately-deployed BFF service only when a second consumer
|
||||
(mobile/partner) or a team boundary demands it — not before.
|
||||
|
||||
### Why DTOs *decouple* rather than couple
|
||||
|
||||
The coupling people fear comes from **not** having DTOs — i.e. serializing internal
|
||||
DB/domain entities straight onto the wire, so every schema change ripples to the
|
||||
client. A DTO is the decoupling seam:
|
||||
|
||||
```
|
||||
DB entity / domain model → DTO (the wire contract) → FE view model
|
||||
(backend's own) (the agreed contract) (frontend's own)
|
||||
```
|
||||
|
||||
Each side keeps its own internal model and refactors freely; only the DTO is a
|
||||
deliberate, versioned change. The one coupling that remains — both sides agreeing
|
||||
on the contract — is the *wanted*, reviewable seam. Manage it with **one source of
|
||||
truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
|
||||
the governance/transparency artifact.
|
||||
|
||||
### Two shapes of "policy over the wire" — pick per rule
|
||||
|
||||
- **Config value** — for simple thresholds. Server sends the value; the FE applies
|
||||
it for instant feedback; **the backend re-validates on submit as the authority.**
|
||||
Example here: the scholing threshold.
|
||||
- **Decision flag** — for anything non-trivial/sensitive. Server computes the
|
||||
boolean (optionally with a `reason`); the FE just renders it. Example here:
|
||||
herregistratie eligibility.
|
||||
|
||||
The frontend keeps only **format** validation (postcode shape, integer parsing) for
|
||||
instant feedback — never as the authority.
|
||||
|
||||
## Worked example in this POC
|
||||
|
||||
This POC has no real backend (static mock JSON + fake submit timers), so the
|
||||
"BFF output" is a static file; the `decisions` block stands in for what the backend
|
||||
would compute. Two slices were implemented to demonstrate **both** policy shapes:
|
||||
|
||||
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
|
||||
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
|
||||
(`DashboardViewDto` = registration + person + `decisions`).
|
||||
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
|
||||
- Boundary parse: `parseDashboardView()` in
|
||||
`src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
|
||||
untrusted shape and maps DTO → domain (hand-written; no schema lib for one
|
||||
contract).
|
||||
- `BigProfileStore` now derives `profile` and `decisions` from the single
|
||||
validated view (was a 3-resource `map2`). One request → one consistent snapshot.
|
||||
- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of
|
||||
calling `isHerregistratieEligible()`. That rule is now marked server-owned in
|
||||
`registration.policy.ts` (kept as reference impl + unit test; FE no longer calls it).
|
||||
- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`,
|
||||
`brp.json`) were deleted — those calls live behind the BFF now.
|
||||
|
||||
**B. Intake scholing threshold → config value.**
|
||||
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
|
||||
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
|
||||
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;
|
||||
`lageUren(a, scholingThreshold)` and validation take the value, which lives in
|
||||
machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT`
|
||||
remains only as the offline fallback.
|
||||
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
|
||||
|
||||
## Migration sequence (for the real app)
|
||||
|
||||
1. Define the contract in OpenAPI/TypeSpec; generate types for FE and BE.
|
||||
2. Stand up screen-shaped endpoints on the existing backend that aggregate the
|
||||
upstreams and compute `decisions`.
|
||||
3. Point each screen at its single endpoint; delete client-side aggregation.
|
||||
4. Move each hardwired rule server-side; expose as decision flag or config value.
|
||||
5. Reduce the FE to format-validation + rendering.
|
||||
|
||||
## Out of scope here (next steps, not built in the worked example)
|
||||
|
||||
- Runtime DTO validation on **every** endpoint (only the dashboard view has it).
|
||||
- Optimistic-update race fix in `BigProfileStore`
|
||||
(`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under
|
||||
concurrent submits).
|
||||
- Session persistence / multi-tab sync (`SessionStore` is in-memory).
|
||||
- Real OpenAPI/TypeSpec codegen toolchain.
|
||||
|
||||
ponytail: build the pattern once on one slice; copy it across screens when the real
|
||||
backend lands, rather than scaffolding all of it up front.
|
||||
112
docs/ui-ux-audit.md
Normal file
112
docs/ui-ux-audit.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# UI/UX audit — BIG-register wizard (Rijkshuisstijl alignment)
|
||||
|
||||
Phase A deliverable. Audits the registration wizard and the shared atoms/chrome it
|
||||
relies on against the project's design system (`@rijkshuisstijl-community`, the NL Design
|
||||
System Rijkshuisstijl theme — the canonical system here; CIBG's own DS is not installed)
|
||||
and WCAG 2.1 AA. Each finding maps to the token / component / pattern that resolves it.
|
||||
This is an **alignment, not a redesign**: fixes go through the token/theme layer and
|
||||
existing components; no new libraries, no restricted Rijksoverheid assets.
|
||||
|
||||
Scope: registratie wizard + shared atoms/chrome (intake/herregistratie inherit the shared
|
||||
fixes). Their own step copy/inline styles are out of scope.
|
||||
|
||||
Priority: **H** = blocks "professional/accessible" bar (a11y or broken token); **M** =
|
||||
visible inconsistency; **L** = polish.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tokens vs. hardcoded values
|
||||
|
||||
The app imports RHC design tokens but the wizard/chrome were written with **raw inline
|
||||
values** (≈48 in wizard+chrome, ≈16 in atoms). RHC exposes a full token set we should map
|
||||
onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
|
||||
`--rhc-text-font-weight-*`, color `--rhc-color-foreground-{default,subtle}` /
|
||||
`--rhc-color-{lintblauw,donkerblauw,cool-grey}-*` / `--rhc-color-wit`, radius
|
||||
`--rhc-border-radius-*`.
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` |
|
||||
| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b` — `lintblauw-900` doesn't exist (only 50–700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` |
|
||||
| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` |
|
||||
| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once |
|
||||
| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` |
|
||||
| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` |
|
||||
|
||||
## 2. Typography & heading hierarchy
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 2.1 | **H** | Each page has one `<h1>` (page-shell) but the **wizard steps have no `<h2>`** — step title is a plain `<p>`. Screen-reader users get no step heading to navigate to. | per-step `<h2>` via `app-heading [level]="2"` |
|
||||
| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` |
|
||||
|
||||
## 3. Color & contrast
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA |
|
||||
| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens |
|
||||
|
||||
## 4. Spacing & layout
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` |
|
||||
| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
|
||||
|
||||
## 5. Component reuse
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` |
|
||||
| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) |
|
||||
|
||||
## 6. Form, validation & status patterns
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` |
|
||||
| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` |
|
||||
| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` |
|
||||
| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` |
|
||||
| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) |
|
||||
|
||||
## 7. Page chrome
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` |
|
||||
| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) |
|
||||
| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
|
||||
|
||||
## 8. Copy & tone
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) |
|
||||
|
||||
## 9. Accessibility (WCAG 2.1 AA) — consolidated
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change |
|
||||
| 9.2 | **H** | Error/label/invalid association missing (6.1–6.3). | as above |
|
||||
| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` |
|
||||
| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` |
|
||||
| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps |
|
||||
|
||||
## 10. Responsive
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
|---|-----|---------|-------------|
|
||||
| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check |
|
||||
|
||||
---
|
||||
|
||||
## Tooling note
|
||||
|
||||
No eslint/stylelint/axe-core in the repo, and the prime directive forbids adding
|
||||
dependencies. Token compliance is enforced by a **no-dep `check:tokens` npm script**
|
||||
(greps touched templates for raw `#hex` and bare `rem/px` outside `var(...)`);
|
||||
accessibility uses the already-installed **Storybook `addon-a11y`** plus a manual
|
||||
keyboard/screen-reader pass.
|
||||
Reference in New Issue
Block a user