docs(skills): extract house recipes as Claude Code skills for SSP templating
8 template-generic skills in .claude/skills/ (new-feature, new-context, value-object, form-machine, bff-endpoint, mutation-command, ui-component, new-ssp), condensed from CLAUDE.md/ARCHITECTURE/fp-tea/ADRs and pointing at this repo's worked examples. CLAUDE.md gains a pointer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
57
.claude/skills/bff-endpoint/SKILL.md
Normal file
57
.claude/skills/bff-endpoint/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: bff-endpoint
|
||||
description: Add a screen-shaped read — backend endpoint returning a decision-enriched DTO, regenerated typed client, contracts DTO, adapter with parse boundary, application store, <app-async> in the page. Use for any new data a page needs.
|
||||
---
|
||||
|
||||
# BFF-lite read endpoint (decision DTO)
|
||||
|
||||
One screen = one endpoint returning everything that screen needs, **decisions
|
||||
included**. The FE renders the server's decisions; it never recomputes business
|
||||
rules (ADR-0001). Per rule choose: **decision flag** (server computes the boolean)
|
||||
or **config value** (server sends the threshold, FE applies it for instant feedback,
|
||||
server re-validates as authority).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Backend** (`backend/src/BigRegister.Api/`): minimal-API endpoint under `/api/v1`,
|
||||
wire DTO in `Contracts/`, rule in `Domain/` with a test in
|
||||
`backend/tests/BigRegister.Tests/`. Rejections are ProblemDetails (RFC 7807, 422).
|
||||
2. **Regenerate the client**: `npm run gen:api` — commits `backend/swagger.json` +
|
||||
`src/app/shared/infrastructure/api-client.ts` (CI has a drift check; never edit the generated file).
|
||||
3. **DTO** in `<context>/contracts/<name>.dto.ts` — plain interfaces/string-literal
|
||||
unions. Contracts import **nothing** (lint-enforced): no Angular, no aliases, no relative imports.
|
||||
4. **Adapter** in `<context>/infrastructure/<name>.adapter.ts` — the only place HTTP lives:
|
||||
|
||||
```ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardViewAdapter {
|
||||
private client = inject(ApiClient);
|
||||
viewResource() { return resource({ loader: () => this.client.dashboardView() }); }
|
||||
}
|
||||
// The trust boundary: validate the untrusted shape AND map DTO → domain.
|
||||
export function parseDashboardView(json: unknown): Result<string, DashboardView> { … }
|
||||
```
|
||||
|
||||
The mapping may read as an identity copy today — the point is the **types** differ,
|
||||
so the compiler forces a real mapping the moment the wire diverges.
|
||||
5. **Application store** exposes the resource as `RemoteData` (`fromResource`,
|
||||
combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`).
|
||||
UI never imports infrastructure (lint-enforced).
|
||||
6. **Page** renders through `<app-async>` (`shared/ui/async`) — one of
|
||||
loading/empty/error/loaded by construction. Check all four states with the
|
||||
dev-only `?scenario=slow|loading|empty|error` toggle.
|
||||
|
||||
## Worked example (trace one name through all layers)
|
||||
|
||||
`dashboard-view`: `backend/src/BigRegister.Api` endpoint →
|
||||
`src/app/registratie/contracts/dashboard-view.dto.ts` →
|
||||
`src/app/registratie/infrastructure/dashboard-view.adapter.ts` (+ spec on `parseDashboardView`) →
|
||||
`src/app/registratie/application/big-profile.store.ts` → dashboard page.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
cd backend && dotnet test && cd ..
|
||||
npm run gen:api && git diff --exit-code src/app/shared/infrastructure/api-client.ts
|
||||
npm test && npm run lint && npm run build
|
||||
```
|
||||
70
.claude/skills/form-machine/SKILL.md
Normal file
70
.claude/skills/form-machine/SKILL.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: form-machine
|
||||
description: Add a form or wizard as an Elm-style state machine (Model/Msg/pure reduce) — the one idiom for anything with validation or submission, one step or many. Use instead of hand-rolled mutable fields + ad-hoc error signals.
|
||||
---
|
||||
|
||||
# Form machine (Model / Msg / reduce)
|
||||
|
||||
If you're about to add a second boolean to track state, stop — model a discriminated
|
||||
union. Fields exist only in the states that need them, so illegal states are
|
||||
unrepresentable.
|
||||
|
||||
## Skeleton
|
||||
|
||||
`<context>/domain/<name>.machine.ts` (pure TS, no Angular imports):
|
||||
|
||||
```ts
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
|
||||
export interface Draft { postcode: string; uren: string } // raw strings as typed
|
||||
export type StepErrors = Partial<Record<keyof Draft, string>>;
|
||||
export interface Valid { postcode: Postcode; uren: Uren } // branded, proven valid
|
||||
|
||||
export type State =
|
||||
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors }
|
||||
| { tag: 'Submitting'; data: Valid }
|
||||
| { tag: 'Submitted'; data: Valid }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
|
||||
export type Msg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Seed'; state: State }; // mount any state (stories, resume)
|
||||
|
||||
export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} };
|
||||
|
||||
export function reduce(s: State, m: Msg): State {
|
||||
switch (m.tag) {
|
||||
/* … pure transitions only … */
|
||||
default: return assertNever(m); // exhaustiveness enforced
|
||||
}
|
||||
}
|
||||
|
||||
export function validate(draft: Draft): Result<StepErrors, Valid> { /* calls value-object parsers */ }
|
||||
```
|
||||
|
||||
Rules:
|
||||
- **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill).
|
||||
- **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field.
|
||||
- Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine).
|
||||
- Co-located `.machine.spec.ts` is **required**: drive `reduce` with messages, assert states. No TestBed.
|
||||
|
||||
## Wiring in the UI
|
||||
|
||||
The organism holds `createStore(initial, reduce)` (`@shared/application/store`) as a
|
||||
field initializer, derives view state via `computed` + `whenTag(state, 'Editing')`,
|
||||
and renders into `<app-wizard-shell>` (`shared/layout/wizard-shell`) — status, steps,
|
||||
errors, and primary/back/retry outputs map 1:1 onto the machine.
|
||||
|
||||
## Worked examples
|
||||
|
||||
- `src/app/herregistratie/domain/herregistratie.machine.ts` — canonical multi-step + submit lifecycle.
|
||||
- `src/app/herregistratie/domain/intake.machine.ts` — progressive disclosure, derive-don't-store, server-owned threshold.
|
||||
- `src/app/herregistratie/ui/` — the wizard organism + page composition.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
npm test && npm run lint && npm run build
|
||||
```
|
||||
58
.claude/skills/mutation-command/SKILL.md
Normal file
58
.claude/skills/mutation-command/SKILL.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: mutation-command
|
||||
description: Add a write/submit operation — infrastructure adapter method plus an application command factory returning Result, keeping the reducer pure. Use for any POST/PUT/DELETE a form or action triggers.
|
||||
---
|
||||
|
||||
# Mutation command
|
||||
|
||||
Reducer = "what the new state is"; command = "go do it, then say what happened".
|
||||
The UI holds an application command, never the network client (lint-enforced).
|
||||
|
||||
## Skeleton
|
||||
|
||||
**Adapter method** (`<context>/infrastructure/<name>.adapter.ts`) — takes the
|
||||
machine's `Valid` type, returns the server's answer, no parse (server is authority):
|
||||
|
||||
```ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChangeRequestAdapter {
|
||||
private client = inject(ApiClient);
|
||||
async changeRequest(data: Valid): Promise<string> { /* → server reference */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Command factory** (`<context>/application/submit-<name>.ts`) — binds the adapter in
|
||||
an injection context; `runSubmit` is the one try/catch + ProblemDetails mapping:
|
||||
|
||||
```ts
|
||||
export function createSubmitChangeRequest() {
|
||||
const adapter = inject(ChangeRequestAdapter);
|
||||
return (data: Valid): Promise<Result<string, string>> =>
|
||||
runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED);
|
||||
}
|
||||
```
|
||||
|
||||
**Caller** (organism, field initializer — same shape as `createStore`): on the
|
||||
machine's `Submitting` state, run the command, then dispatch
|
||||
`SubmitConfirmed` / `SubmitFailed { error }`. Never throw into the template.
|
||||
|
||||
## Optimistic updates on shared state
|
||||
|
||||
When a root store's view must reflect the write before the server confirms:
|
||||
`begin*` (flip pending) → `confirm*` (clear + `resource.reload()`) / `rollback*`
|
||||
(undo). See `src/app/registratie/application/big-profile.store.ts`.
|
||||
|
||||
## Worked examples
|
||||
|
||||
- `src/app/registratie/infrastructure/change-request.adapter.ts`
|
||||
- `src/app/registratie/application/submit-change-request.ts`
|
||||
- `src/app/registratie/application/draft-sync.ts` — `createDraftSync(...)` bundles
|
||||
draft persistence + submit for wizard flows; prefer it when the form has a draft.
|
||||
- `src/app/shared/application/submit.ts` — `runSubmit`, `SUBMIT_FAILED`.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
npm test && npm run lint && npm run build
|
||||
# exercise the failure path in the browser: ?scenario=error on the submitting page
|
||||
```
|
||||
51
.claude/skills/new-context/SKILL.md
Normal file
51
.claude/skills/new-context/SKILL.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: new-context
|
||||
description: Scaffold a new DDD bounded context (folders, path alias, eslint boundary rules, lazy route). Use when adding a new business capability that doesn't belong in an existing context.
|
||||
---
|
||||
|
||||
# New bounded context
|
||||
|
||||
A context is a business **capability**, not a user group — a user group is an actor
|
||||
that may span contexts (ADR-0002). Check first whether the capability belongs in an
|
||||
existing context; new contexts are rare.
|
||||
|
||||
Naming: domain contexts are **Dutch** (`registratie`, `herregistratie`); only
|
||||
shared/reusable code is English. The context name is the ubiquitous language term.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Folders** — `src/app/<ctx>/{domain,application,infrastructure,ui}` (`contracts/`
|
||||
only once it gets a wire seam). Empty layers can wait; don't scaffold placeholders.
|
||||
2. **Path alias** — add `"@<ctx>/*": ["src/app/<ctx>/*"]` to `tsconfig.json` `paths`.
|
||||
Aliases are direction statements; always import cross-context via the alias.
|
||||
3. **eslint boundaries** (`eslint.config.mjs`) — dependencies point inward and
|
||||
toward `shared` only. Copy the existing per-context block (the `brief` block is
|
||||
the minimal leaf-context example) and:
|
||||
- add a block for `src/app/<ctx>/**/*.ts` banning imports from every context it
|
||||
may **not** depend on;
|
||||
- add `@<ctx>/*` to the ban lists of `shared/**` and every context that must not
|
||||
depend on the new one (grep the config for `@brief/*` to find all lists);
|
||||
- the generic blocks (`domain/**` framework-free, `contracts/**` import-nothing,
|
||||
ApiClient confinement, `ui/**` never imports `infrastructure`) match by glob
|
||||
and cover the new context automatically.
|
||||
4. **Route** — lazy child under the persistent shell in `app.routes.ts`:
|
||||
|
||||
```ts
|
||||
{ path: '<ctx>', canActivate: [authGuard],
|
||||
loadComponent: () => import('@<ctx>/ui/<ctx>.page').then(m => m.CtxPage) }
|
||||
```
|
||||
|
||||
5. Build the first feature slice with the **new-feature** skill.
|
||||
|
||||
## Worked example
|
||||
|
||||
`src/app/brief/` — an independent leaf context (depends only on shared): see its
|
||||
folder layout and its eslint block in `eslint.config.mjs`.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
npm run lint && npm run build
|
||||
# prove the fence works: add a forbidden import (e.g. new ctx → @herregistratie/*),
|
||||
# confirm lint fails, remove it.
|
||||
```
|
||||
45
.claude/skills/new-feature/SKILL.md
Normal file
45
.claude/skills/new-feature/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: new-feature
|
||||
description: Add a feature (screen, flow, capability) to this Angular SSP following the house pipeline — domain first, then infrastructure, application, UI last. Use whenever building anything user-facing that has business rules or data.
|
||||
---
|
||||
|
||||
# New feature (the pipeline)
|
||||
|
||||
Every feature is built inward-out. Never start with the component.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Domain** (`<context>/domain/`, pure TS, no Angular imports — lint-enforced)
|
||||
- Data types + pure rules. Forms/wizards get a `*.machine.ts` → use the **form-machine** skill.
|
||||
- Raw input that must be valid → branded value object → use the **value-object** skill.
|
||||
- Co-locate a `.spec.ts`; test the pure functions directly, no TestBed.
|
||||
- Server-owned rules stay here only as reference impl + test, marked server-owned; the FE renders the server's decision (ADR-0001).
|
||||
|
||||
2. **Infrastructure** (`<context>/infrastructure/`, the only layer that touches HTTP)
|
||||
- Read → screen-shaped endpoint + adapter → use the **bff-endpoint** skill.
|
||||
- Write → command adapter returning `Result` → use the **mutation-command** skill.
|
||||
|
||||
3. **Application** (`<context>/application/`)
|
||||
- Shared cross-page state → `providedIn: 'root'` store exposing `RemoteData` signals.
|
||||
- Page-local flow state → `createStore(initial, reduce)` from `@shared/application/store`, held in the component.
|
||||
- Side effects live in commands (`submit-*.ts`), never in the reducer.
|
||||
|
||||
4. **UI last** (`<context>/ui/`)
|
||||
- A page is **composition of existing blocks** — `<app-page-shell>`, `<app-async>`, atoms/molecules from `shared/ui`. Adding a new building block is the exception → **ui-component** skill.
|
||||
- Templates dispatch messages, never mutate. Async data always renders through `<app-async>`.
|
||||
- All copy via `$localize` with stable `@@context.key` ids.
|
||||
- Route: lazy `loadComponent` under the `ShellComponent` parent in `app.routes.ts`, `canActivate: [authGuard]` when protected.
|
||||
|
||||
## Worked example
|
||||
|
||||
The intake wizard slice, end to end:
|
||||
- `src/app/herregistratie/domain/intake.machine.ts` (+ spec)
|
||||
- `src/app/herregistratie/infrastructure/`
|
||||
- `src/app/herregistratie/ui/` (wizard component + page)
|
||||
|
||||
## Verify (GREEN gate)
|
||||
|
||||
```bash
|
||||
npm run lint && npm run check:tokens && npm test && npm run build
|
||||
cd backend && dotnet test # if the backend changed
|
||||
```
|
||||
55
.claude/skills/new-ssp/SKILL.md
Normal file
55
.claude/skills/new-ssp/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: new-ssp
|
||||
description: Bootstrap a new self-service portal from this repo as a template — what to keep, strip, rename, and re-seed. Use when starting a new SSP for a different domain/register.
|
||||
---
|
||||
|
||||
# New SSP from this template
|
||||
|
||||
The template's value is the **enforced architecture** (layer fences, token gate,
|
||||
a11y gate, API-drift gate) and the shared building blocks — not the BIG-register
|
||||
business content. Keep the machinery, replace the domain.
|
||||
|
||||
## Keep as-is
|
||||
|
||||
- `src/app/shared/` — kernel (`fp.ts`), application (`remote-data`, `store`,
|
||||
`submit`), ui atoms/molecules, layout templates, upload subtree.
|
||||
- Tooling: `eslint.config.mjs`, `scripts/check-tokens.sh`, `.github/workflows/ci.yml`,
|
||||
`nswag.json`, `.storybook/`, `proxy.conf.json`, `.npmrc` (`legacy-peer-deps` —
|
||||
and never `npm audit fix --force`, it downgrades Angular).
|
||||
- `src/app/auth/` (fake auth shell) and `src/app/shared/infrastructure/scenario.interceptor.ts` (dev-only).
|
||||
- `docs/architecture/` ADRs 0001–0003 — the decisions still apply; amend, don't delete.
|
||||
- `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/fp-tea-atomic-design.md` — update names/examples as contexts change.
|
||||
- `.claude/skills/` — these recipes are the point of the template.
|
||||
|
||||
## Strip / replace
|
||||
|
||||
- Business contexts `registratie/`, `herregistratie/`, `brief/`, and `showcase/`:
|
||||
delete or keep one slice temporarily as the worked example while building the
|
||||
first real context (**new-context** + **new-feature** skills). If deleted, update
|
||||
the worked-example paths in these skills to the new flagship context.
|
||||
- `app.routes.ts` routes and `tsconfig.json` aliases for removed contexts, plus
|
||||
their eslint blocks in `eslint.config.mjs`.
|
||||
- Backend: keep the skeleton (`Program.cs` minimal-API style, ProblemDetails 422,
|
||||
`X-Correlation-Id` audit line, `/api/v1` versioning, `Contracts/`/`Domain/`/`Data/`
|
||||
split, test project) — replace `Data/SeedData.cs`, `Domain/*` rules, and
|
||||
`Contracts/*` DTOs with the new domain's. Rename the solution/projects from
|
||||
`BigRegister.*` (also update `package.json` `gen:api` and `ci.yml` paths).
|
||||
- Regenerate the seam: `npm run gen:api` (commits `backend/swagger.json` +
|
||||
`src/app/shared/infrastructure/api-client.ts`).
|
||||
- Branding: `public/cibg-huisstijl/` + the token bridge in `src/styles.scss` — for a
|
||||
different house style, swap the vendored CSS and re-point the `--rhc-*` bridge
|
||||
(ADR-0003 pattern: bridge, don't rewrite tokens).
|
||||
- `docs/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
|
||||
(keep `docs/backlog/README.md`'s WP process/template if you like the workflow).
|
||||
|
||||
## Verify — the GREEN gate must pass at every step
|
||||
|
||||
```bash
|
||||
npm run lint && npm run check:tokens && npm test && npm run build
|
||||
npm run build-storybook && npm run test-storybook:ci
|
||||
cd backend && dotnet test && cd ..
|
||||
npm run gen:api && git diff --exit-code backend/swagger.json src/app/shared/infrastructure/api-client.ts
|
||||
```
|
||||
|
||||
Strip incrementally and keep this green — the fences are only worth having if they
|
||||
never go red.
|
||||
53
.claude/skills/ui-component/SKILL.md
Normal file
53
.claude/skills/ui-component/SKILL.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: ui-component
|
||||
description: Add a shared UI building block (atom, molecule, organism) with its Storybook story. Use only after confirming no existing block in shared/ui fits — new blocks are the exception, composition is the default.
|
||||
---
|
||||
|
||||
# UI component (atom / molecule / organism)
|
||||
|
||||
First: check `shared/ui/` and `shared/layout/` — a new page should be composition of
|
||||
existing blocks. Only add a block when nothing fits.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Folder = atomic layer**: `shared/ui/` atoms → molecules → organisms;
|
||||
`shared/layout/` templates. Each level only uses levels below.
|
||||
- Standalone component, **English name** (shared = language-agnostic), signal
|
||||
`input()`s only, `inject()` over constructor DI.
|
||||
- **Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) classes** (`btn`,
|
||||
`form-control`, `card`, …) — you own a small typed `input()` API, the design system
|
||||
owns the visuals. No CIBG class for it? Hand-roll a small surface from the token
|
||||
bridge (ADR-0003, e.g. the `alert` atom).
|
||||
- **Tokens only** — `var(--rhc-*)` / `var(--app-*)`, never hardcoded colors
|
||||
(`npm run check:tokens` fails the build; escape hatch: `token-ok` marker + reason).
|
||||
- **No hardcoded Dutch** in shared components — expose copy as `input()`s with
|
||||
`$localize` defaults; the domain caller supplies the text (see `shared/ui/async`).
|
||||
- Components with content-projected slots export a spread constant so callers import
|
||||
one thing: `export const ASYNC = [AsyncComponent, AsyncLoadedDirective, …] as const;`
|
||||
|
||||
## Story (required — this is the component's test)
|
||||
|
||||
Co-located `<name>.stories.ts`, title prefixed with the layer:
|
||||
|
||||
```ts
|
||||
const meta: Meta<ButtonComponent> = { title: 'Atoms/Button', component: ButtonComponent };
|
||||
export const Primary: StoryObj<ButtonComponent> = { args: { variant: 'primary' } };
|
||||
```
|
||||
|
||||
One named export per meaningful state. CI runs axe on every story
|
||||
(`test-storybook:ci`) — a11y failures break the build. Machines/wizards mount
|
||||
specific states via the `Seed` message. No heavy component specs; Storybook is the
|
||||
UI test surface.
|
||||
|
||||
## Worked examples
|
||||
|
||||
- Atom: `src/app/shared/ui/button/` — typed variant API over `btn` classes.
|
||||
- Molecule: `src/app/shared/ui/async/` — slot directives, localizable input defaults, spread constant.
|
||||
- Template: `src/app/shared/layout/wizard-shell/` — the canonical wizard outline.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
npm run lint && npm run check:tokens && npm run build
|
||||
npm run storybook # eyeball the story; CI will run axe
|
||||
```
|
||||
55
.claude/skills/value-object/SKILL.md
Normal file
55
.claude/skills/value-object/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: value-object
|
||||
description: Add a validated input type (postcode, email, hours, id number, …) as a branded value object with a parser — "parse, don't validate". Use whenever a form field or API value has format rules.
|
||||
---
|
||||
|
||||
# Value object (parse, don't validate)
|
||||
|
||||
Raw input becomes a branded type only via a parser returning `Result`. Once you hold
|
||||
the type, never re-check it. Never model validity as a boolean flag next to a string.
|
||||
|
||||
## Skeleton
|
||||
|
||||
`<context>/domain/value-objects/<name>.ts` (pure TS, no Angular):
|
||||
|
||||
```ts
|
||||
import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
||||
|
||||
export type Postcode = Brand<string, 'Postcode'>;
|
||||
|
||||
export function parsePostcode(raw: string): Result<string, Postcode> {
|
||||
const t = raw.trim().toUpperCase();
|
||||
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
|
||||
return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);
|
||||
}
|
||||
// The parser also normalises — callers always hold the canonical form.
|
||||
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- The `as Brand` cast appears **only** inside the parser — the type is mintable nowhere else.
|
||||
- Error message is user-facing → `$localize` with a stable `@@validation.<name>` id.
|
||||
- Trim/normalise before testing; return the cleaned value.
|
||||
- This is **format** feedback only. The server re-validates as authority (ADR-0001) — never encode business rules (existence, eligibility) here.
|
||||
|
||||
## Spec (required)
|
||||
|
||||
Co-located `<name>.spec.ts`: happy path, normalisation, each rejection case. Call the
|
||||
parser directly — no TestBed.
|
||||
|
||||
## Wiring into a form
|
||||
|
||||
The machine's `validate(draft)` calls the parsers and collects errors into
|
||||
`StepErrors`; the `Valid` type holds the branded values (see **form-machine** skill).
|
||||
|
||||
## Worked examples
|
||||
|
||||
`src/app/registratie/domain/value-objects/` — `postcode.ts`, `email.ts`, `uren.ts`,
|
||||
`big-nummer.ts`, each with a co-located spec.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
npm test && npm run lint
|
||||
```
|
||||
@@ -161,6 +161,10 @@ Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter
|
||||
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
|
||||
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
|
||||
|
||||
The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
|
||||
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
|
||||
`ui-component`, `new-ssp` (bootstrap a new portal from this template).
|
||||
|
||||
## Out of scope (POC, don't build unprompted)
|
||||
|
||||
Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark),
|
||||
|
||||
Reference in New Issue
Block a user