docs(skills): extract house recipes as Claude Code skills for SSP templating
Some checks failed
CI / frontend (push) Successful in 2m16s
CI / storybook-a11y (push) Successful in 4m6s
CI / backend (push) Failing after 48s
CI / api-client-drift (push) Successful in 1m32s

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:
2026-07-03 10:10:38 +02:00
parent 0f360c5939
commit cf44bda0ce
9 changed files with 448 additions and 0 deletions

View 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
```

View 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
```

View 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
```

View 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.
```

View 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
```

View 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 00010003 — 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.

View 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
```

View 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
```

View File

@@ -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>`, union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
dispatch messages). Worked example: the intake wizard (`herregistratie/`). 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) ## Out of scope (POC, don't build unprompted)
Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark), Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark),