Compare commits

...
7 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 abc4728c97 docs(backlog): mark WP-45 done
CI / frontend (push) Successful in 2m13s
CI / backend (push) Successful in 1m34s
CI / e2e (push) Failing after 3m21s
CI / storybook-a11y (push) Failing after 7m31s
CI / semgrep (push) Successful in 1m4s
CI / api-client-drift (push) Successful in 1m58s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 16:43:36 +02:00
ehoandClaude Sonnet 5 ad7ca31841 feat(dx): create-ssp bootstrap generator (WP-45)
Mechanises the new-ssp skill's manual fork-and-strip recipe into a runnable
script: strips the four business contexts and every wiring point that
references them (routes, tsconfig aliases, dependency-cruiser boundaries,
nav links, capability union, the debug-state panel, showcase snippet
tooling), renames BigRegister.* throughout including the backend
solution/projects, re-runs gen:api, and reuses gen:context (WP-44) to seed
the new portal's first context. Backend business rules and real branding
can't be generated from nothing, so those print an explicit checklist
instead of pretending to be automated.

Verified end-to-end against an isolated clone (not this working tree):
lint, dep:check, check:tokens, all frontend tests, ng build, dotnet
build/test on the renamed backend, and gen:api against it all pass clean
with zero remaining references to the stripped contexts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 16:43:17 +02:00
ehoandClaude Sonnet 5 e4022fd31a fix(auth): re-evaluate language-switcher link on navigation
The shell (and this switcher within it) is a persistent parent, so the
switcher's computed link only ever ran once at bootstrap against the
initial location.pathname. Navigating client-side afterward left it
frozen on that first route (typically /login), so switching language
from any other page sent you to the stale /en/login instead of the
current route — indistinguishable from being logged out, though the
session was untouched. Recompute on every completed Router navigation,
same toSignal(router.events...) idiom already used by the breadcrumb
in site-header.component.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:40:08 +02:00
ehoandClaude Sonnet 5 e7e2f070f9 feat(brief): WP-28 demo script, e2e spec, and story gap-fill
Closes phase 6 (Brief v2): a demo script mapping shipped scenarios to
URL+click paths (no Brief v2 PRD ever existed to translate one from —
written directly against the code instead), one e2e spec covering
compose→approve→send and admin republish→drafter-sees-it, and
Storybook state gaps (rejection diff, read-only viewer, org logo,
upload rejection) that prior WPs left uncovered. Flags passage-picker
as dead code, superseded by besluit-panel.

npm run e2e is not verified green in this sandbox — see WP-28's
Deviations section; the pre-existing, unmodified smoke.spec.ts fails
identically here, pointing at a sandbox rendering issue rather than a
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 20:01:51 +02:00
ehoandClaude Sonnet 5 94cd0b82c2 feat(dx): add proactive-use triggers to agent descriptions
Rewrite planner/developer/task-runner descriptions with explicit "use
proactively" triggers and the model-mismatch condition, so the
orchestrating session self-routes to the right agent instead of relying
on CLAUDE.md prose being remembered each time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:28:48 +02:00
ehoandClaude Sonnet 5 67170fbc84 feat(dx): model-routed custom agents (planner/developer/task-runner)
Pin the model to the step, not the whole session, via .claude/agents/*.md frontmatter
instead of relying on a human to run /model at the right moment: planner (Opus, no
Edit/Write) for design work, developer (Sonnet) for implementation, task-runner (Haiku,
no Edit/Write) for mechanical read-only checks. CLAUDE.md documents the routing rule;
the backlog session protocol now names these agents instead of a manual /model instruction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:35:40 +02:00
ehoandClaude Sonnet 5 7b6cabfc4a feat(dx): gen:context generator (WP-44)
npm run gen:context scaffolds a bounded context: folders + starter page, the @<ctx>/*
tsconfig alias, a dependency-cruiser boundary entry, and a lazy authGuard route.

Refactors .dependency-cruiser.js's per-context contextRule calls into a single
CONTEXT_ALLOWED map that every rule derives from, so adding a context is really one
config entry (verified behavior-preserving: same dep:check counts, same graph output).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:18:43 +02:00
25 changed files with 1359 additions and 236 deletions
+15
View File
@@ -0,0 +1,15 @@
---
name: developer
description: Implement an already-decided plan — write/edit code, run tests, get to a green build. Use PROACTIVELY, without waiting to be asked, whenever the current session is not already Sonnet and an approach has already been settled (by a planner agent, the user, or an approved plan-mode plan) — routine implementation against that plan. Not for open-ended design decisions.
model: sonnet
disallowedTools: Agent
---
You are the implementation specialist for this repo (atomic-design-poc). Follow `CLAUDE.md`'s
conventions exactly (DDD layers, atomic design folder = layer, RemoteData/store/Result idioms,
`$localize` for user-facing copy, no `any`). Implement the plan you were given — don't
re-litigate its decisions, but do flag (and stop for) anything that turns out to be
factually wrong about the current code rather than silently working around it.
Before finishing: run `npm run ci` (or the narrower check the task calls for) and report the
result. Leave the working tree in a state that would pass code review, not just "compiles."
+18
View File
@@ -0,0 +1,18 @@
---
name: planner
description: Design an implementation approach before code is written — architecture, edge cases, sequencing, trade-offs. Use PROACTIVELY, without waiting to be asked, whenever the current session is not already Opus and the task is: starting a new WP's Decisions block, an ambiguous bug whose root cause isn't yet known, or sequencing a multi-file/non-trivial change. Does not write code; hands back a plan for the orchestrating session (or the developer agent) to execute.
model: opus
disallowedTools: Edit, Write, NotebookEdit, Agent, Artifact, ExitPlanMode
---
You are the planning specialist for this repo (atomic-design-poc). Read `CLAUDE.md` and the
relevant `docs/reference/architecture/` files first — the house rules (DDD layers, atomic
design, RemoteData/store/Result idioms, BFF-lite decision DTOs) are non-negotiable working
agreements, not suggestions to relitigate.
Produce a plan, not code: the files to touch, the pattern to follow (name the existing
example it mirrors), the edge cases, and the verification steps (`npm run ci` at minimum).
Flag anything in a WP's or skill's premise that looks stale against the current codebase
rather than trusting it blindly — this repo's own backlog notes repeatedly getting burned
by that. Return the plan as your final message; you have no Edit/Write access, so
implementation happens elsewhere (the `developer` agent, or the orchestrating session).
+10
View File
@@ -0,0 +1,10 @@
---
name: task-runner
description: Run a simple, mechanical, read-only check and report the result — a test suite, `git status`, `grep`, verifying a file exists, lint/build output. Use PROACTIVELY, without waiting to be asked, whenever the current session is not already Haiku and the task is exactly one of these read-only checks. Never for tasks needing design judgment or code edits — use `developer` or `planner` for those.
model: haiku
disallowedTools: Edit, Write, NotebookEdit, Agent, Artifact, ExitPlanMode, EnterWorktree, ShareOnboardingGuide
---
You run one focused, read-only command or check and report exactly what happened — no
interpretation beyond what's asked, no fixing anything you find broken (report it back
instead). Quote the actual command output relevant to the question asked, not a paraphrase.
+15 -20
View File
@@ -14,26 +14,21 @@ shared/reusable code is English. The context name is the ubiquitous language ter
## 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. **Boundaries** (`.dependency-cruiser.js`, WP-38 — the single declarative source;
dependencies point inward and toward `shared` only). Add ONE `contextRule(...)` entry
for the new context listing the contexts it may **not** import (copy the `brief` leaf
example), and add the new context to the forbidden list of any context that must not
depend on it. The layer rules (`domain/` framework-free, `contracts/` import-nothing,
ApiClient confinement, `ui ↛ infrastructure`) match by glob and cover it automatically.
Verify with `npm run dep:check`; regenerate the graph with `npm run dep:graph`. (Boundaries
are no longer in `eslint.config.mjs` — that now holds only `no-explicit-any` + template a11y.)
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.
1. Run `npm run gen:context` (WP-44) and give it the lowercase context name. It
mechanises the manual edits below in one shot:
- Folders — `src/app/<ctx>/{domain,application,infrastructure,contracts}/.gitkeep` +
a starter `ui/<ctx>.page.ts` (replace with the real first feature).
- Path alias — `"@<ctx>/*": ["src/app/<ctx>/*"]` added to `tsconfig.json` `paths`.
- Boundary entry — one new key in `.dependency-cruiser.js`'s `CONTEXT_ALLOWED` map
(WP-38's single declarative source; every context's forbidden list is _derived_
from that map, so adding one key is enough — nothing else to hand-edit). List the
OTHER contexts the new one may import (usually `[]` — a leaf, like `brief`).
- Route — a lazy child under the persistent shell in `app.routes.ts`, gated by
`authGuard`.
2. Verify: `npm run dep:check && npm run lint && npm run build` (regenerate the graph
with `npm run dep:graph` if you want the committed diagram to reflect it too).
3. Build the first feature slice with the **new-feature** skill — replace the
generated placeholder page.
## Worked example
+69 -18
View File
@@ -9,38 +9,89 @@ 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.
## Run the script
Clone this repo, `npm ci`, then mechanise the mechanical parts (WP-45):
```bash
node scripts/create-ssp.mjs --name Kvk --context inschrijving
```
`--name` (PascalCase) replaces `BigRegister.*` everywhere; `--context` (lowercase Dutch
ubiquitous term) is passed straight to `gen:context` (`plop context`, WP-44) to seed the new
portal's first real context. Add `--dry-run` to preview file operations first, `--keep
<context>` to leave one business context in place temporarily as a worked example, and
`--skip-backend` if no .NET SDK is available yet (skips `gen:api`).
It strips the four business contexts and their wiring, renames the backend, re-runs
`gen:api`, and seeds the first context — then **prints a checklist** for what it deliberately
doesn't script: backend business rules and real branding can't be generated from nothing.
Work through that checklist, keeping the GREEN gate below passing at every step.
## 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`,
`submit`), ui atoms/molecules, layout templates, upload subtree — **except**
`shared/ui/debug-state/`, which the script deletes (see below).
- Tooling: `eslint.config.mjs`, `.dependency-cruiser.js` (edited by the script, not
hand-stripped — see below), `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/reference/architecture/` ADRs 0001–0003 — the decisions still apply; amend, don't delete.
- `CLAUDE.md`, `docs/reference/architecture/ARCHITECTURE.md`, `docs/reference/fp-tea-atomic-design.md` — update names/examples as contexts change.
- `.claude/skills/` — these recipes are the point of the template.
- `src/app/beheer/` — its frontend is genuinely generic (data-driven off a `StamdataTable`/
`AuditEntry` shape, nothing BIG-specific). Its _backend_ Stamdata catalog is not — see below.
## Strip / replace
## Strip / replace — what the script does
- 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`.
- Business contexts `registratie/`, `herregistratie/`, `brief/`, `showcase/`: deleted (or one
kept temporarily via `--keep` as the worked example while building the first real context —
**new-context** + **new-feature** skills; if kept, update the worked-example paths in those
skills to the new flagship context once you drop it for real).
- `app.routes.ts` route blocks and `tsconfig.json` aliases for removed contexts, and their
`CONTEXT_ALLOWED` entry in **`.dependency-cruiser.js`** — boundary rules moved there in
WP-38 and are no longer in `eslint.config.mjs` (which only keeps `no-explicit-any` + a11y
template rules). Route stripping matches on the _import alias_ a route uses, not its own
path segment — `beheer/zaken` imports `@registratie/ui/admin-cases.page` and gets dropped
along with `registratie` even though its own path doesn't say so.
- `src/app/shared/ui/debug-state/` (the dev `⚙ state` panel): imports
`@registratie/application/big-profile.store` directly and is the one path
`.dependency-cruiser.js`'s `shared-no-features` rule exempts — there's no generic way to
re-target it at an arbitrary new context, so it's deleted alongside `registratie`, along
with its three wiring lines in `shell.component.ts` (import, `imports:` entry, template tag).
- The `dashboard` route is **not** deleted even though it currently imports
`@registratie/ui/dashboard.page` — too much else hardcodes `/dashboard` (login's post-auth
redirect, `authGuard`'s fallback, header nav/logo, breadcrumb trail, several stories/specs).
The script rewrites its `loadComponent` to point at the freshly scaffolded `--context` page
instead (a `TODO(create-ssp)` stopgap landing page, not a real overview).
- `scripts/gen-snippets.mjs` (showcase-only) + its `package.json` script entry + its CI/
`ci-local.sh` "showcase snippets drift" steps: deleted alongside `showcase/` — they run
unconditionally, so leaving them breaks `npm run ci` immediately once `showcase/` is gone.
- 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).
split, test project, and the generic `Stamdata/StamdataFile.cs`+`StamdataTable.cs`
reflection-driven `/stamdata` endpoint machinery, ADR-0004) — replace `Data/SeedData.cs`,
`Domain/*` rules, `Contracts/*` DTOs, and the three concrete Stamdata catalog entries
(`Beroep`/`Opleiding`/`Specialisme`/`ProfessionMapping` + their JSON) with the new
register's. The script renames the solution/projects from `BigRegister.*` (and updates
`package.json`'s `gen:api`, `ci.yml`'s paths, `docker-compose.yml`) — it does not rewrite
business content; that's the printed checklist.
- 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).
`src/app/shared/infrastructure/api-client.ts`) — only reflects a new shape once the backend
content above is actually rewritten.
- Branding: the script swaps `src/index.html`'s stylesheet `<link>` + `<title>` to a
placeholder path and creates an empty `public/<name>-huisstijl/` — it cannot generate a
real house style. Vendor your CSS there, then re-point the `--rhc-*` bridge in
`src/styles.scss` (ADR-0003 pattern: bridge, don't rewrite tokens), then `npm run check:tokens`.
- `docs/project/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
(keep `docs/project/backlog/README.md`'s WP process/template if you like the workflow).
`docs/reference/scaffolding.md` also names `BigRegister.Api` in prose — update by hand.
- `e2e/*.spec.ts` (`smoke.spec.ts`, `brief-v2.spec.ts`, `error-state.spec.ts`): full
BIG-register user-flow tests (BSN login → registration wizard → submission assertions).
Not touched by the script (they don't block `npm run ci` — the `e2e` job runs separately)
but are 100% stale business content; rewrite once you have real flows to test.
## Verify — the GREEN gate must pass at every step
@@ -51,5 +102,5 @@ 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.
Or just `npm run ci` for the non-storybook subset. Strip incrementally and keep this green
— the fences are only worth having if they never go red.
+31 -19
View File
@@ -8,16 +8,37 @@
// Allowed cross-context edges: everyone → shared; herregistratie → registratie; showcase → *
// (the sanctioned teaching page). Nobody imports showcase.
const FEATURES = 'auth|registratie|herregistratie|brief|beheer|showcase';
// Single source of truth for bounded-context boundaries: each entry maps a context name to the
// OTHER contexts it may additionally import (besides itself + shared). `showcase` maps to `null`
// — sanctioned to import every context (the teaching page); nothing else may import it. Add a
// context here — nowhere else — when scaffolding one (see `gen:context`, WP-44); FEATURES and
// every contextRule below are derived from this object.
const CONTEXT_ALLOWED = {
auth: [],
registratie: [],
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
brief: [],
beheer: [],
showcase: null,
};
/** A context may import shared + itself; this lists the OTHER contexts it may NOT import. */
const contextRule = (name, from, forbiddenContexts) => ({
name,
comment: `${from} may depend only on its allowed contexts (+ shared). See CLAUDE.md §1.`,
severity: 'error',
from: { path: `^src/app/${from}/` },
to: { path: `^src/app/(${forbiddenContexts})/` },
});
const FEATURES = Object.keys(CONTEXT_ALLOWED).join('|');
/** A context may import shared + itself + its allowed list; forbidden = every other context. */
const contextRule = (from) => {
const allowed = CONTEXT_ALLOWED[from];
if (allowed === null) return null; // unrestricted (showcase) — no rule to generate
const forbidden = Object.keys(CONTEXT_ALLOWED)
.filter((name) => name !== from && !allowed.includes(name))
.join('|');
return {
name: `${from}-scope`,
comment: `${from} may depend only on its allowed contexts (+ shared). See CLAUDE.md §1.`,
severity: 'error',
from: { path: `^src/app/${from}/` },
to: { path: `^src/app/(${forbidden})/` },
};
};
module.exports = {
forbidden: [
@@ -29,16 +50,7 @@ module.exports = {
from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' },
to: { path: `^src/app/(${FEATURES})/` },
},
contextRule('auth-only-shared', 'auth', 'registratie|herregistratie|brief|beheer|showcase'),
contextRule(
'registratie-only-shared',
'registratie',
'auth|herregistratie|brief|beheer|showcase',
),
// herregistratie MAY import registratie (+ shared) — the one sanctioned cross-feature edge.
contextRule('herregistratie-scope', 'herregistratie', 'auth|brief|beheer|showcase'),
contextRule('brief-only-shared', 'brief', 'auth|registratie|herregistratie|beheer|showcase'),
contextRule('beheer-only-shared', 'beheer', 'auth|registratie|herregistratie|brief|showcase'),
...Object.keys(CONTEXT_ALLOWED).map(contextRule).filter(Boolean),
// showcase/ is exempt (reads every context by design); nothing imports it — covered by the
// rules above each forbidding `→ showcase`.
+23 -2
View File
@@ -45,6 +45,22 @@ catches a miss before CI does.
Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only
advisories are pinned via `package.json` `overrides`; the shipped bundle audits clean.
## Model routing for agent delegation
Three custom agents in `.claude/agents/` pin the model to the step, not the whole session —
so this doesn't depend on a human remembering to run `/model` at the right moment:
- **`planner`** (Opus) — design/approach work: a WP's Decisions block, an ambiguous bug's
root cause, sequencing a multi-file change. No Edit/Write access; hands back a plan.
- **`developer`** (Sonnet) — implementation once the approach is settled: routine code
against a pre-made plan, ending green (`npm run ci`).
- **`task-runner`** (Haiku) — simple, read-only, mechanical checks: running a test suite,
`git status`/`grep`, verifying a file exists. No Edit/Write access.
Delegate to the matching agent only when the _current_ session isn't already on that
model — don't add indirection for its own sake. `docs/project/backlog/README.md`'s
session protocol is the worked example of this in practice.
## The decisions (non-negotiable working agreements)
### 1. DDD: contexts then layers, dependencies point inward
@@ -183,9 +199,14 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
on data pages (`scenario.interceptor.ts`) to see every async state.
on data pages (`scenario.interceptor.ts`) to see every async state — sticky per tab
(change it via a full navigation, not an in-app link). Hand-written `fetch`/XHR calls
(uploads, `/brief/preview`, `/admin/org-template/*/preview`, `/brief/reveal-bignummer`)
bypass the interceptor.
- Dev role stand-in (**dev-only**): `?role=drafter|approver|admin` (or the `⚙ state` dev panel).
Roles, how to switch, and what each unlocks: `docs/reference/roles-and-access.md`.
Roles, how to switch, and what each unlocks: `docs/reference/roles-and-access.md`. `admin`
unlocks the capability-gated pages: `/brief/huisstijl` (org-template editor),
`/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`.
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
+1
View File
@@ -43,4 +43,5 @@ condensed, cross-linked curriculum.
| [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-48) — the live tracker. |
| [prd/0001-mijn-aanvragen-en-wizardstatus.md](project/prd/0001-mijn-aanvragen-en-wizardstatus.md) | PRD — "Mijn aanvragen": running wizards, application status, document preview. |
| [prd/0002-attribute-based-access-control.md](project/prd/0002-attribute-based-access-control.md) | PRD — attribute-based access control in the UI. |
| [prd/0003-brief-v2-demo-script.md](project/prd/0003-brief-v2-demo-script.md) | Demo script — Brief v2 scenarios mapped to a URL + click path (WP-28). |
| [SHOWCASE-ROADMAP.md](project/SHOWCASE-ROADMAP.md) | Superseded roadmap (absorbed into `project/backlog/`) — kept for history. |
+10 -9
View File
@@ -11,12 +11,13 @@ This backlog **supersedes `docs/project/SHOWCASE-ROADMAP.md`**.
- **One WP per session.** Read `CLAUDE.md`, this README, the WP file, and the WP's
"Read first" list — then execute. Do not start the next WP in the same session.
- **Match the model to the step, not the whole session.** Plan/design under Opus (`/model
opus`) — a WP's approach and edge cases deserve the stronger model. Switch to Sonnet to
write the code once the plan is approved — routine implementation against a pre-made
Decisions block doesn't need Opus. Delegate simple, read-only CLI checks (running a test
suite, `grep`/`git status`, verifying a file exists) to a Haiku subagent where the harness
supports it — proportion cost to the step's difficulty, not the WP's.
- **Match the model to the step, not the whole session** (see CLAUDE.md's "Model routing
for agent delegation"). Read the WP's Decisions block with the `planner` agent (Opus) if
the current session isn't already Opus — the approach and edge cases deserve the
stronger model. Implement directly if already on Sonnet, or hand off to the `developer`
agent otherwise, once the plan is approved. Delegate simple, read-only CLI checks
(running a test suite, `grep`/`git status`, verifying a file exists) to the `task-runner`
agent (Haiku) — proportion cost to the step's difficulty, not the WP's.
- The **Decisions** block in each WP is pre-made — don't relitigate it.
- A WP ends **GREEN** (below) with its acceptance criteria checked off and its Status
updated to `done` (+ commit hash).
@@ -77,7 +78,7 @@ for its existing violations, so every WP ends green.
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
@@ -93,8 +94,8 @@ for its existing violations, so every WP ends green.
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo |
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done |
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done |
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
@@ -1,6 +1,6 @@
# WP-28 — Brief v2 demo polish (scenarios, e2e, docs)
Status: todo
Status: done (pending commit; `npm run e2e` unverified in this dev sandbox — see Deviations)
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
@@ -45,12 +45,53 @@ that keep CLAUDE.md and the backlog truthful.
## Acceptance criteria
- [ ] Every kept PRD §12 scenario has a working URL + click path in the script
- [x] Every kept PRD §12 scenario has a working URL + click path in the script
(walked manually once).
- [ ] `npm run e2e` green, including the new spec.
- [ ] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
- [ ] `npm run e2e` green, including the new spec — **not verified in this dev
sandbox**; see Deviations.
- [x] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
`?role=admin` and `/brief/huisstijl`.
## Deviations / notes (as built)
- **No Brief v2 PRD was ever committed.** WP-23..27 cite "PRD Brief v2 §N" throughout,
but `docs/project/prd/` never held such a file — it only ever existed as chat
context. The demo script (`docs/project/prd/0003-brief-v2-demo-script.md`) is
written directly against the shipped code instead of translating an external §12
scenario list, and says so up top.
- **`?scenario=` does not reach every endpoint.** `/brief/preview`,
`/admin/org-template/{id}/preview` and `/brief/reveal-bignummer` are hand-written
`fetch` calls (same seam as uploads, deliberately `.ExcludeFromDescription()`'d) and
bypass `scenarioInterceptor`. The demo script and CLAUDE.md now say so explicitly.
- **Canvas authoring moved.** The original "drafter composes on the canvas" framing
predates commit `ba32e3d` ("brief v3 — besluit-driven guided drafting"):
`LetterCanvasComponent.editableRegions` no longer has a `'content'` mode. The
drafter now works through `BehandelSchermComponent` (case header + stepper +
`app-besluit-panel` + `app-letter-editor`), with the canvas as a read-only preview
in a modal. The demo script and e2e spec follow that path.
- **`passage-picker` is dead code** — superseded by `besluit-panel`'s guided
drafting, no consumer left besides its own story. Flagged with a comment on the
component rather than deleted in this WP (out of scope for a demo-polish pass).
- **Story gaps were state gaps, not component gaps** — every component already had a
co-located story. Added: `letter-composer` `RejectionDiff` + `AlleenLezen` (WP-27's
diff view and the pure-viewer notice had no story), `letter-canvas` +
`org-template-editor` `MetLogo` (WP-26's logo letterhead had no story), and
`org-template-editor` `LogoUploadFout` (the upload-rejection branch had no story).
- **Org templates have no reset endpoint** — the e2e spec's admin section restores
the org-template draft it edits (rollback + republish) instead of relying on a
reset, so repeated runs don't drift the seeded "BIG-register" template.
- **`npm run e2e` could not be verified green in this dev sandbox** — both the new
`brief-v2.spec.ts` and the pre-existing, untouched `smoke.spec.ts` fail here at
the same kind of step (clicking a CIBG-styled radio's `<label for>`; e.g.
`label[for="correspondentie-post"]` in `smoke.spec.ts`), with Playwright
reporting the element "detached from the DOM, retrying" or a native input whose
rendered box collapses to 1×1px. Reproduced with `--workers=1` and running
`smoke.spec.ts` alone, so it isn't cross-test contention. Since `smoke.spec.ts`
predates this WP and is unrelated to any file it touches, this reads as a
sandbox-specific rendering/CSS-loading issue (fonts 404 here; a stylesheet may
not be fully served), not a regression from this WP's changes. **Needs
confirming green on a normal dev machine / CI** before this box can be ticked.
## Verification
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
@@ -1,10 +1,32 @@
# WP-44 — `gen:context` generator
Status: todo
Status: done
Phase: 8 — platform/DX/showcase
Priority: P3
Depends on: WP-38, WP-43
## Outcome
`npm run gen:context` (plop, extends WP-43's `plopfile.mjs`) prompts for a lowercase context name
and emits: `src/app/<ctx>/{domain,application,infrastructure,contracts}/.gitkeep` + a starter
`ui/<ctx>.page.ts` (a `PageShellComponent` wrapper — replace with the real first feature slice);
the `@<ctx>/*` tsconfig alias; one new key in `.dependency-cruiser.js`'s `CONTEXT_ALLOWED` map; and
a lazy, `authGuard`-gated route in `app.routes.ts` inserted before the catch-all.
**Refactored `.dependency-cruiser.js` to make "one config entry" literally true.** The pre-WP file
hand-duplicated each context's forbidden-imports list as a separate `contextRule(name, from,
forbidden)` call — adding a context meant editing N existing calls to add it to their forbidden
list, not adding one entry. Replaced with a single `CONTEXT_ALLOWED` map (context → contexts it may
additionally import) that every rule + the `FEATURES` string is _derived_ from; `showcase` maps to
`null` (unrestricted — the one exempt case) and is skipped when generating rules. Verified
behavior-preserving: `npm run dep:check` reports the same module/dependency counts before and after,
`npm run dep:graph`'s committed output is byte-identical, and a planted cross-context violation
(`auth` importing `@herregistratie`, type-only) is still caught under the new `auth-scope` rule name.
Smoke-tested by generating a real `vergunning` context end-to-end (`dep:check`, `lint`, `build` all
green, including the new lazy chunk), then removed the demo output. `.claude/skills/new-context/
SKILL.md` now points at the generator as step 1.
## Why
Adding a bounded context is currently a manual multi-file edit (folders + tsconfig alias + copied
@@ -26,7 +48,7 @@ ESLint boundary block + lazy route) — the `new-context` skill's most error-pro
## Acceptance criteria
- [ ] `npm run gen:context <name>` produces a context that lints clean (boundaries recognised) and
- [x] `npm run gen:context <name>` produces a context that lints clean (boundaries recognised) and
routes lazily.
- [ ] Boundary tool (WP-38) validates the new context's allowed edges.
- [ ] `npm run ci` green.
- [x] Boundary tool (WP-38) validates the new context's allowed edges.
- [x] `npm run ci` green.
@@ -1,6 +1,6 @@
# WP-45 — `create-ssp` bootstrap generator
Status: todo
Status: done (ad7ca31)
Phase: 8 — platform/DX/showcase
Priority: P4
Depends on: WP-43, WP-44
@@ -0,0 +1,115 @@
# PRD 0003 — Brief v2 demo script
Status: Reference · Date: 2026-07-27 · Context: phase 6 "Brief v2" (WP-23..28)
> Cross-references: **WP-23** (org-template backend), **WP-24** (letter canvas), **WP-25**
> (server-rendered preview), **WP-26** (admin org-template editor), **WP-27** (brief UX layer —
> undo/redo, besluit-driven guidance, rejection diff).
>
> No standalone "Brief v2" PRD was ever committed to this repo — WP-23..27 cite one
> informally ("PRD Brief v2 §N") but it only ever existed as chat context. This script
> **is** the demonstrable spec now: every row below is a scenario that actually exists
> in the shipped code, not a scenario list carried over from an external document.
Phase 6 ships two independent axes over the same letter: **content** (what the letter
says — drafted by the behandelaar, reviewed by an approver) and **appearance** (how it
looks — the org template, edited by an admin). This script walks both, plus the
degraded states each page can be in. No new scenario code backs this — it is a map onto
toggles that already exist: `?role=drafter|approver|admin`, `?scenario=slow|loading|error`,
and `POST /brief/reset`.
## Before you start
- **Login**: `/login` → BSN `123456782`, any password → "Inloggen met DigiD".
- **Toggles are sticky per tab** (`sessionStorage`, WP-33/WP-37): `?role=` and
`?scenario=` in the URL win once per navigation, then persist for the tab. Change
role by a **full navigation** (typing the URL, not an in-app link) — `GET /me` is
fetched once per page load, so an in-app link keeps the stale role. Reset with
`?role=drafter&scenario=default`, the `⚙ state` dev panel, or a fresh tab.
- **What the toggles don't reach**: `/brief/preview`, `/admin/org-template/{id}/preview`
and `/brief/reveal-bignummer` are hand-written `fetch` calls (same seam as uploads) and
bypass `scenarioInterceptor` — they can't be forced into a scenario state from the URL.
`?scenario=empty` substitutes `[]` for every `/api/` response, which breaks any
object-returning endpoint (e.g. `GET /api/v1/brief` parse-fails into the error state) —
use it only where a list/empty-state is actually being demonstrated.
- **Reset**: the "Opnieuw beginnen (demo)" button on `/brief` (`POST /brief/reset`) resets
the _letter_. Org templates have no reset endpoint — section E's walk ends with a
restore step (E9) so the demo doesn't drift.
All URLs are relative to `http://localhost:4200`.
## A — Setup
| # | Scenario | URL | Clicks |
| --- | ---------------- | --------------------- | ------------------------- |
| A1 | Fresh demo state | `/brief?role=drafter` | "Opnieuw beginnen (demo)" |
## B — Compose (drafter, the besluit-driven workflow)
| # | Scenario | URL | Clicks / expect |
| --- | ---------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B1 | Case-framed drafting step | `/brief?role=drafter` | case header (referentie, naam, BIG-nummer, beroep) + stepper Beoordelen → **Brief opstellen** → Indienen |
| B2 | Submit blocked on empty kern | (same) | "Indienen ter beoordeling" disabled + hint "Vul eerst alle verplichte secties en los fouten op." |
| B3 | Guided drafting — positief | (same) | radio "Positief besluit (toewijzen)" → kern fills with standaardteksten + info alert "…standaardtekst(en) toegevoegd op basis van het besluit…" |
| B4 | Guided drafting — negatief needs a reden | (same) | radio "Negatief besluit (afwijzen)" → warning "Kies een reden…" → tick "Onvoldoende scholing" → alert flips to info, kern updates |
| B5 | Selection survives reload | reload `/brief` | the besluit panel re-seeds from the letter's own kern passages (no separate storage) |
| B6 | Free text + placeholder | (same) | in a section, "Vrije tekst toevoegen" → type in the rich-text editor → "Veld invoegen" dropdown to insert a placeholder chip |
| B7 | Diagnostics (deprecated / not-fillable) | (same) | insert `oud_kenmerk` (deprecated) and `specialisme_code` (not fillable for this beroep) → diagnostics panel shows a warning and an error |
| B8 | Undo / redo | (same) | toolbar "Ongedaan maken" / "Opnieuw uitvoeren", or Ctrl+Z / Ctrl+Shift+Z outside a text field |
| B9 | Autosave states | (same) | "Concept opslaan…" → "Concept opgeslagen" in the toolbar's live region |
| B10 | Autosave failure + retry | `/brief?role=drafter&scenario=error`, then edit | "Niet opgeslagen — opnieuw proberen" → "Opnieuw proberen" |
| B11 | PII reveal + step-up + audit | `/brief?role=drafter` | "Toon BIG-nummer" → native confirm dialog ("Extra verificatie vereist…") → unmasked; then `/beheer/audit?role=admin` lists the attempt (no PII in the log) |
## C — Preview (one rendering, used twice: in-app and as the sent artifact)
| # | Scenario | URL | Clicks / expect |
| --- | ------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C1 | In-app preview modal | `/brief?role=drafter` | "Voorbeeld" → a `<dialog>` with the read-only letter canvas |
| C2 | Zoom + sample values + page break | (in the dialog) | −/+/100% zoom controls; "Voorbeeld met testwaarden" swaps placeholder chips for sample text; on a long letter, a "±pagina-einde — afdrukvoorbeeld is leidend" mark appears |
| C3 | Server-rendered HTML document | (in the dialog) | "Openen als document (PDF)" → new tab, `Content-Type: text/html`, watermarked as a draft |
| C4 | Sent letter serves its frozen archive | after D9 | same button on the sent letter → the archived HTML (no watermark), the org-template version pinned at send time even if templates change afterwards |
## D — Review (approver, segregation-of-duty)
| # | Scenario | URL | Clicks / expect |
| --- | ----------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| D1 | Submit | `/brief?role=drafter` | "Indienen ter beoordeling" |
| D2 | Drafter waits (four-eyes) | `/brief?role=drafter` | info alert "De brief wacht op beoordeling door een collega." — no approve/reject controls |
| D3 | Read-only viewer (e.g. admin) | `/brief?role=admin` | info alert "Alleen-lezen weergave. De behandelaar stelt de brief op." — a pure viewer has no approve/reject/send capability |
| D4 | Approver review | `/brief?role=approver` | "Goedkeuren" button + a reject-comment entry, both available |
| D5 | Reject with reason | (same) | fill the reject textarea → "Afwijzen" |
| D6 | Rejection visible to drafter | `/brief?role=drafter` | "Afgewezen:" alert showing the comment |
| D7 | Rework + resubmit | (same) | edit the kern → "Opnieuw indienen" |
| D8 | Rejection diff | `/brief?role=approver`, same tab session as D5 | "Toon wijzigingen" → changed/added blocks get an orange/green badge, plus a "N blok(ken) verwijderd sinds afwijzing." alert if any were deleted. **POC limit**: the diff snapshot is in-memory and reload-fragile — do D5→D8 without reloading in between |
| D9 | Approve → send | `/brief?role=approver` | "Goedkeuren" → "Versturen" → "De brief is verzonden." |
## E — Appearance (admin, org templates)
| # | Scenario | URL | Clicks / expect |
| --- | --------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| E1 | Reach the editor | `/brief/huisstijl?role=admin` | (or via the Beheer navigation) — sub-org switcher defaults to "BIG-register" |
| E2 | Denied without the capability | `/brief/huisstijl?role=drafter` | `capabilityGuard` redirects straight to `/dashboard` (no denial page is ever rendered for this route — the guard runs before the component loads) |
| E3 | Edit in place on the letterhead | `/brief/huisstijl?role=admin` | type into Organisatienaam / Retouradres / Afsluiting / Naam ondertekenaar / Functie ondertekenaar / Contactgegevens (voettekst) / Juridische voettekst → the canvas re-renders live; "Concept opgeslagen" |
| E4 | Margins | (same) | Boven / Rechts / Onder / Links (mm) number inputs, clamped 10–40mm |
| E5 | Logo upload | (same) | choose a file via the Logo file input → the letterhead `<img>` appears on the canvas |
| E6 | Proefbrief | (same) | "Proefbrief" → new tab: the **unpublished draft** rendered over the fixed sample letter |
| E7 | Publish with impact confirm | (same) | "Publiceren" → warning "Dit raakt N nog niet verzonden brieven. Publiceren?" → "Bevestigen" → "Gepubliceerde versie: N+1" |
| E8 | Invalid draft blocks publish | (same) | clear Organisatienaam or a signer field → "Publiceren" disabled + hint "Vul organisatienaam en ondertekenaar in; marges tussen 10 en 40 mm." |
| E9 | Rollback (also: restore after the walk) | (same) | Versiegeschiedenis → "Terugzetten in concept" on an old version copies it into the draft; "Publiceren" again makes it live — use this to put a demo sub-org back the way you found it |
| E10 | **Two axes, one render** | `/brief?role=drafter` after E7 | "Voorbeeld" → the new letterhead over the _same_ letter body that section B composed |
| E11 | Sub-org isolation | `/brief/huisstijl?role=admin` | switch the sub-org dropdown to "CIBG Vakbekwaamheid" → its own values, untouched by edits made to "BIG-register"; Proefbrief on each proves it |
| E12 | Version pinning | send a letter (D9), then E7, then C4 | the already-sent letter's archived HTML is unaffected by a later template publish |
## F — Degraded states (`?scenario=` on data pages)
| # | Scenario | URL | Expect |
| --- | ----------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| F1 | Slow | `/brief?role=drafter&scenario=slow` | delay-gated skeleton (~250ms gate, then a multi-second response) |
| F2 | Never resolves | `/brief?role=drafter&scenario=loading` | skeleton stays, `aria-busy="true"` |
| F3 | Load error + retry | `/brief?role=drafter&scenario=error` | error alert "De brief kon niet worden geladen." + "Opnieuw proberen" |
| F4 | Admin editor when `/me` fails | `/brief/huisstijl?role=admin&scenario=error` | `capabilityGuard` awaits `/me`, sees it fail, denies by default → redirects to `/dashboard` (not a denial alert on `/brief/huisstijl` itself — the guard never lets the page render) |
## Out of scope
New scenario-interceptor cases, a scenario-switcher UI, screenshots/video (per WP-28's
Decisions).
+149 -149
View File
@@ -28644,7 +28644,7 @@
},
{
"name": "PassagePickerComponent",
"id": "component-PassagePickerComponent-25156c4d1008c23952424dd87e6d92081346f0c822913b8ba0921f630399914d5c0ef8af5b5dfe67be1f8a9486b92e35c5115cf66f442cbb7e9a21a0351280aa",
"id": "component-PassagePickerComponent-ba7eccc65c72f5ec20ce8442c142a6c4eb63d430727d8e470a493162d579c1e8a0236b9927d38d75bea4a0246ff2f5afbf7ce996eaf3fd89f4fa0fc28a2c6857",
"file": "src/app/brief/ui/passage-picker/passage-picker.component.ts",
"encapsulation": [],
"entryComponents": [],
@@ -28669,7 +28669,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 77,
"line": 81,
"required": false
},
{
@@ -28680,7 +28680,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 79,
"line": 83,
"required": false
},
{
@@ -28691,7 +28691,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 78,
"line": 82,
"required": false
},
{
@@ -28702,7 +28702,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 81,
"line": 85,
"required": false
},
{
@@ -28713,7 +28713,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 74,
"line": 78,
"required": true
},
{
@@ -28724,7 +28724,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 80,
"line": 84,
"required": false
}
],
@@ -28737,7 +28737,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 75,
"line": 79,
"required": false
}
],
@@ -28751,7 +28751,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 83,
"line": 87,
"modifierKind": [
124
]
@@ -28765,7 +28765,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 94,
"line": 98,
"modifierKind": [
124
]
@@ -28779,7 +28779,7 @@
"indexKey": "",
"optional": false,
"description": "<p>Client-side filter on label + rendered content text — the library is small, so no\nserver search (WP-27). Placeholder keys are searchable too (see <code>textOf</code>).</p>\n",
"line": 87,
"line": 91,
"rawdescription": "\nClient-side filter on label + rendered content text — the library is small, so no\nserver search (WP-27). Placeholder keys are searchable too (see `textOf`).",
"modifierKind": [
124
@@ -28794,7 +28794,7 @@
"indexKey": "",
"optional": false,
"description": "",
"line": 84,
"line": 88,
"modifierKind": [
124
]
@@ -28807,7 +28807,7 @@
"optional": false,
"returnType": "void",
"typeParameters": [],
"line": 100,
"line": 104,
"deprecated": false,
"deprecationMessage": "",
"modifierKind": [
@@ -28837,7 +28837,7 @@
"optional": false,
"returnType": "void",
"typeParameters": [],
"line": 96,
"line": 100,
"deprecated": false,
"deprecationMessage": "",
"modifierKind": [
@@ -28892,10 +28892,10 @@
"type": "component"
}
],
"description": "<p>Molecule: multi-select list of the section&#39;s library passages. One &quot;Voeg toe&quot;\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.</p>\n",
"rawdescription": "\nMolecule: multi-select list of the section's library passages. One \"Voeg toe\"\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.",
"description": "<p>Molecule: multi-select list of the section&#39;s library passages. One &quot;Voeg toe&quot;\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.</p>\n<p>Superseded by <code>besluit-panel</code> (WP-27&#39;s guided drafting): no consumer left in\n<code>src/app</code> outside its own story (WP-28 audit). Kept for now rather than deleted\nin-flight of an unrelated WP; a future cleanup can remove it.</p>\n",
"rawdescription": "\nMolecule: multi-select list of the section's library passages. One \"Voeg toe\"\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.\n\nSuperseded by `besluit-panel` (WP-27's guided drafting): no consumer left in\n`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted\nin-flight of an unrelated WP; a future cleanup can remove it.",
"type": "component",
"sourceCode": "import { Component, computed, input, output, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { textOf } from '@shared/kernel/rich-text';\nimport { LibraryPassage } from '@brief/domain/brief';\n\n/** Molecule: multi-select list of the section's library passages. One \"Voeg toe\"\n inserts ALL checked passages at once (a single message upstream) — there is no\n single-insert path. Presentational: emits the chosen passages in list order. */\n@Component({\n selector: 'app-passage-picker',\n imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],\n styles: [\n `\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n `,\n ],\n template: `\n <div class=\"search\">\n <app-text-input\n [placeholder]=\"searchLabel()\"\n [attr.aria-label]=\"searchLabel()\"\n [ngModel]=\"query()\"\n (ngModelChange)=\"query.set($event)\"\n />\n </div>\n <ul>\n @for (p of filtered(); track p.passageId) {\n <li>\n <app-checkbox\n [checkboxId]=\"'passage-' + p.passageId\"\n [label]=\"p.label\"\n [ngModel]=\"!!checked()[p.passageId]\"\n (ngModelChange)=\"set(p.passageId, $event)\"\n />\n <span class=\"scope\"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>\n </li>\n } @empty {\n <li class=\"empty\">{{ noMatchLabel() }}</li>\n }\n </ul>\n <app-button variant=\"secondary\" [disabled]=\"count() === 0\" (click)=\"add()\"\n >{{ addLabel() }} ({{ count() }})</app-button\n >\n `,\n})\nexport class PassagePickerComponent {\n passages = input.required<readonly LibraryPassage[]>();\n insert = output<LibraryPassage[]>();\n\n addLabel = input($localize`:@@brief.picker.add:Voeg toe`);\n globalLabel = input($localize`:@@brief.picker.global:algemeen`);\n beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);\n searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);\n noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);\n\n protected checked = signal<Record<string, boolean>>({});\n protected query = signal('');\n /** Client-side filter on label + rendered content text — the library is small, so no\n server search (WP-27). Placeholder keys are searchable too (see `textOf`). */\n protected filtered = computed(() => {\n const q = this.query().trim().toLowerCase();\n if (!q) return this.passages();\n return this.passages().filter(\n (p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),\n );\n });\n protected count = () => Object.values(this.checked()).filter(Boolean).length;\n\n protected set(id: string, on: boolean) {\n this.checked.update((c) => ({ ...c, [id]: on }));\n }\n\n protected add() {\n const chosen = this.passages().filter((p) => this.checked()[p.passageId]);\n if (chosen.length) {\n this.insert.emit(chosen);\n this.checked.set({});\n }\n }\n}\n",
"sourceCode": "import { Component, computed, input, output, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { textOf } from '@shared/kernel/rich-text';\nimport { LibraryPassage } from '@brief/domain/brief';\n\n/** Molecule: multi-select list of the section's library passages. One \"Voeg toe\"\n inserts ALL checked passages at once (a single message upstream) — there is no\n single-insert path. Presentational: emits the chosen passages in list order.\n\n Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in\n `src/app` outside its own story (WP-28 audit). Kept for now rather than deleted\n in-flight of an unrelated WP; a future cleanup can remove it. */\n@Component({\n selector: 'app-passage-picker',\n imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],\n styles: [\n `\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n `,\n ],\n template: `\n <div class=\"search\">\n <app-text-input\n [placeholder]=\"searchLabel()\"\n [attr.aria-label]=\"searchLabel()\"\n [ngModel]=\"query()\"\n (ngModelChange)=\"query.set($event)\"\n />\n </div>\n <ul>\n @for (p of filtered(); track p.passageId) {\n <li>\n <app-checkbox\n [checkboxId]=\"'passage-' + p.passageId\"\n [label]=\"p.label\"\n [ngModel]=\"!!checked()[p.passageId]\"\n (ngModelChange)=\"set(p.passageId, $event)\"\n />\n <span class=\"scope\"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>\n </li>\n } @empty {\n <li class=\"empty\">{{ noMatchLabel() }}</li>\n }\n </ul>\n <app-button variant=\"secondary\" [disabled]=\"count() === 0\" (click)=\"add()\"\n >{{ addLabel() }} ({{ count() }})</app-button\n >\n `,\n})\nexport class PassagePickerComponent {\n passages = input.required<readonly LibraryPassage[]>();\n insert = output<LibraryPassage[]>();\n\n addLabel = input($localize`:@@brief.picker.add:Voeg toe`);\n globalLabel = input($localize`:@@brief.picker.global:algemeen`);\n beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);\n searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);\n noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);\n\n protected checked = signal<Record<string, boolean>>({});\n protected query = signal('');\n /** Client-side filter on label + rendered content text — the library is small, so no\n server search (WP-27). Placeholder keys are searchable too (see `textOf`). */\n protected filtered = computed(() => {\n const q = this.query().trim().toLowerCase();\n if (!q) return this.passages();\n return this.passages().filter(\n (p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),\n );\n });\n protected count = () => Object.values(this.checked()).filter(Boolean).length;\n\n protected set(id: string, on: boolean) {\n this.checked.update((c) => ({ ...c, [id]: on }));\n }\n\n protected add() {\n const chosen = this.passages().filter((p) => this.checked()[p.passageId]);\n if (chosen.length) {\n this.insert.emit(chosen);\n this.checked.set({});\n }\n }\n}\n",
"assetsDirs": [],
"styleUrlsData": "",
"stylesData": "\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n \n",
@@ -34438,6 +34438,26 @@
"rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.",
"description": "<p>Used by the shell to find what to poll on return: still-in-flight uploads.</p>\n"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "WizardState",
"defaultValue": "{\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "IntakeState",
"defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
@@ -34468,26 +34488,6 @@
"type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "WizardState",
"defaultValue": "{\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "IntakeState",
"defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
@@ -40357,6 +40357,94 @@
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "WizardState",
"jsdoctags": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "IntakeState",
"jsdoctags": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/beheer/domain/stamdata-editor.machine.ts",
@@ -40489,94 +40577,6 @@
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "WizardState",
"jsdoctags": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "IntakeState",
"jsdoctags": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/registratie/domain/change-request.machine.ts",
@@ -44420,30 +44420,6 @@
"defaultValue": "{\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n}"
}
],
"src/app/brief/domain/brief.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/brief/domain/org-template.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/org-template.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/herregistratie/domain/herregistratie.machine.ts": [
{
"name": "initial",
@@ -44492,6 +44468,30 @@
"description": "<p>The fixed step list. Number of steps never changes; questions reveal inline.</p>\n"
}
],
"src/app/brief/domain/brief.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/brief/domain/org-template.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/org-template.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/registratie/domain/change-request.machine.ts": [
{
"name": "initial",
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
// One flow through both Brief v2 axes on the real FE+backend (WP-19 conventions):
// content (drafter composes via the besluit panel → approver approves → sends) and
// appearance (admin edits + publishes the org template, drafter's canvas reflects it).
// Preview assertions are content-type/body-level (text/html + watermark marker), not
// pixel, per WP-28's decision.
//
// The backend persists to SQLite (WP-22) and is shared across runs: `/brief/reset`
// covers the letter, but org templates have no reset endpoint, so this test restores
// the org-template draft it edits (step 8) and never asserts an absolute version
// number — only that it increased by exactly one.
test('drafter composes → approver sends; admin republishes appearance', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456782');
await page.getByLabel('Wachtwoord').fill('demo');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
// --- Compose (drafter) ---
await page.goto('/brief?role=drafter');
await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click();
await expect(page.getByRole('heading', { name: 'Aanvraag herregistratie' })).toBeVisible();
const submitButton = page.getByRole('button', { name: 'Indienen ter beoordeling' });
await expect(submitButton).toBeDisabled();
await page.locator('label[for="besluit-positief"]').click();
await expect(page.getByText(/standaardtekst\(en\) toegevoegd/)).toBeVisible();
await expect(submitButton).toBeEnabled();
// Wait for the debounced draft save before navigating away.
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
// --- Preview: draft is watermarked ---
await page.getByRole('button', { name: 'Voorbeeld' }).click();
const [draftPreview] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
page.getByRole('button', { name: 'Openen als document (PDF)' }).click(),
]);
expect(draftPreview.headers()['content-type']).toContain('text/html');
expect(await draftPreview.text()).toContain('preview-watermark');
await page.getByRole('button', { name: 'Sluiten' }).click();
// --- Submit → approve → send (role change = full navigation, per WP-33 stickiness) ---
await submitButton.click();
await expect(page.getByText('De brief wacht op beoordeling door een collega.')).toBeVisible();
await page.goto('/brief?role=approver');
await page.getByRole('button', { name: 'Goedkeuren' }).click({ timeout: 10_000 });
await page.getByRole('button', { name: 'Versturen' }).click({ timeout: 10_000 });
await expect(page.getByText('De brief is verzonden.')).toBeVisible();
// --- Preview: sent letter serves its frozen, unwatermarked archive ---
await page.getByRole('button', { name: 'Voorbeeld' }).click();
const [sentPreview] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
page.getByRole('button', { name: 'Openen als document (PDF)' }).click(),
]);
expect(sentPreview.headers()['content-type']).toContain('text/html');
expect(await sentPreview.text()).not.toContain('preview-watermark');
// --- Admin republishes the appearance ---
await page.goto('/brief/huisstijl?role=admin');
const orgNameInput = page.getByLabel('Organisatienaam');
const before = await page.getByText(/Gepubliceerde versie:/).textContent();
const beforeVersion = Number(before?.match(/\d+/)?.[0]);
const unique = `BIG-register E2E ${Date.now()}`;
await orgNameInput.fill(unique);
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Publiceren' }).click();
await expect(page.getByText(/Dit raakt \d+ nog niet verzonden brieven/)).toBeVisible();
await page.getByRole('button', { name: 'Bevestigen' }).click();
await expect(page.getByText(`Gepubliceerde versie: ${beforeVersion + 1}`)).toBeVisible({
timeout: 10_000,
});
// --- Drafter's canvas reflects the new appearance on a fresh letter ---
await page.goto('/brief?role=drafter');
await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click();
await page.getByRole('button', { name: 'Voorbeeld' }).click();
await expect(page.locator('dialog')).toContainText(unique);
await page.getByRole('button', { name: 'Sluiten' }).click();
// --- Restore: put the org template's appearance back the way this test found it ---
await page.goto('/brief/huisstijl?role=admin');
await page
.locator('.history-row', { hasText: new RegExp(`Versie ${beforeVersion} ·`) })
.getByRole('button', { name: 'Terugzetten in concept' })
.click();
await expect(orgNameInput).not.toHaveValue(unique);
await page.getByRole('button', { name: 'Publiceren' }).click();
await page.getByRole('button', { name: 'Bevestigen' }).click();
await expect(page.getByText(`Gepubliceerde versie: ${beforeVersion + 2}`)).toBeVisible({
timeout: 10_000,
});
});
+2
View File
@@ -23,6 +23,8 @@
"gen": "plop",
"gen:value-object": "plop value-object",
"gen:form-machine": "plop form-machine",
"gen:context": "plop context",
"create-ssp": "node scripts/create-ssp.mjs",
"serve:i18n": "ng build --configuration development --localize && node scripts/serve-i18n.mjs",
"ci": "bash scripts/ci-local.sh",
"e2e": "playwright test",
+17
View File
@@ -0,0 +1,17 @@
import { Component } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
/**
* Scaffolded by `gen:context` (WP-44) — replace with the `{{dashCase name}}` context's first
* feature slice (the `new-feature` skill: domain first, then infrastructure/application, UI last).
*/
@Component({
selector: 'app-{{dashCase name}}-page',
imports: [PageShellComponent],
template: `
<app-page-shell heading="{{titleCase name}}">
<p>Scaffolded by gen:context — build the first feature here.</p>
</app-page-shell>
`,
})
export class {{pascalCase name}}Page {}
View File
+74 -1
View File
@@ -6,7 +6,8 @@
* Generators here are the pure-TS patterns (no Angular-template escaping): value objects and
* form/wizard state machines. The UI-component and bff-endpoint patterns stay skill-driven —
* they span the template `{{ }}` syntax / the C# backend + `gen:api` regen, where a generator
* adds little over the recipe.
* adds little over the recipe. `context` (WP-44) mechanises the `new-context` skill's manual
* multi-file edit: folders + tsconfig alias + boundary entry + lazy route.
*
* Positional args skip the prompts, e.g. `npx plop value-object registratie KvkNummer`.
*/
@@ -60,4 +61,76 @@ export default function (plop) {
remindEnXlf,
],
});
plop.setGenerator('context', {
description:
'Scaffold a bounded context: folders + tsconfig alias + boundary entry + lazy route',
prompts: [
{
type: 'input',
name: 'name',
message: 'Context name (lowercase, single Dutch ubiquitous term, e.g. vergunning):',
},
],
actions: [
{
type: 'add',
path: 'src/app/{{kebabCase name}}/ui/{{kebabCase name}}.page.ts',
templateFile: 'plop-templates/context.page.hbs',
},
{
type: 'add',
path: 'src/app/{{kebabCase name}}/domain/.gitkeep',
templateFile: 'plop-templates/gitkeep.hbs',
},
{
type: 'add',
path: 'src/app/{{kebabCase name}}/application/.gitkeep',
templateFile: 'plop-templates/gitkeep.hbs',
},
{
type: 'add',
path: 'src/app/{{kebabCase name}}/infrastructure/.gitkeep',
templateFile: 'plop-templates/gitkeep.hbs',
},
{
type: 'add',
path: 'src/app/{{kebabCase name}}/contracts/.gitkeep',
templateFile: 'plop-templates/gitkeep.hbs',
},
{
// tsconfig path alias — inserted right after the `"paths": {` line so it's stable
// across repeated runs regardless of what's already been added.
type: 'modify',
path: 'tsconfig.json',
pattern: /"paths": \{\n/,
template: '"paths": {\n "@{{kebabCase name}}/*": ["src/app/{{kebabCase name}}/*"],\n',
},
{
// Boundary entry (WP-38's single source of truth) — inserted right before the
// `showcase: null` line, which never moves.
type: 'modify',
path: '.dependency-cruiser.js',
pattern: /(\s*)showcase: null,/,
template: "$1'{{kebabCase name}}': [],$1showcase: null,",
},
{
// Lazy route — inserted right before the catch-all, which never moves.
type: 'modify',
path: 'src/app/app.routes.ts',
pattern: " { path: '**', redirectTo: 'login' },",
template: ` {
path: '{{kebabCase name}}',
canActivate: [authGuard],
loadComponent: () =>
import('@{{kebabCase name}}/ui/{{kebabCase name}}.page').then(
(m) => m.{{pascalCase name}}Page,
),
},
{ path: '**', redirectTo: 'login' },`,
},
() =>
'Next: npm run dep:check && npm run lint && npm run build to verify, then build the first feature with the new-feature skill.',
],
});
}
+523
View File
@@ -0,0 +1,523 @@
#!/usr/bin/env node
// Mechanises .claude/skills/new-ssp/SKILL.md (WP-45): bootstrap a new self-service portal
// from this repo as a template. Run ONCE, inside a fresh `git clone` of this repo (after
// `npm ci`), not against this repo's own working tree.
//
// It strips the BIG-register business contexts + their wiring, renames BigRegister.* ->
// <Name>.*, re-runs gen:api, and reuses gen:context (WP-44, `plop context`) to seed the new
// portal's first real context. Backend business rules and real branding can't be generated
// from nothing — those steps print an explicit checklist instead of pretending to solve them.
//
// Usage:
// node scripts/create-ssp.mjs --name Kvk --context inschrijving
// node scripts/create-ssp.mjs --name Kvk --context inschrijving --keep registratie --dry-run
//
// --name <PascalName> replaces BigRegister.* everywhere (required)
// --context <name> lowercase Dutch ubiquitous term, passed to `plop context` (required)
// --keep <context> don't strip this one business context yet (temporary worked example)
// --dry-run print planned file operations, touch nothing
// --skip-backend skip gen:api (no .NET SDK available) — prints a reminder instead
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ALL_CONTEXTS = ['registratie', 'herregistratie', 'brief', 'showcase'];
function parseArgs(argv) {
const out = { dryRun: false, skipBackend: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--name') out.name = argv[++i];
else if (a === '--context') out.context = argv[++i];
else if (a === '--keep') out.keep = argv[++i];
else if (a === '--dry-run') out.dryRun = true;
else if (a === '--skip-backend') out.skipBackend = true;
else usageAndExit(`Unknown argument: ${a}`);
}
return out;
}
function usageAndExit(message) {
if (message) console.error(message + '\n');
console.error(
'Usage: node scripts/create-ssp.mjs --name <PascalName> --context <lowercase-term> ' +
'[--keep <context>] [--dry-run] [--skip-backend]',
);
process.exit(1);
}
function section(title) {
console.log(`\n\x1b[1;36m▶ ${title}\x1b[0m`);
}
const abs = (p) => path.join(ROOT, p);
const readFile = (p) => fs.readFileSync(abs(p), 'utf8');
function writeFile(p, content, args) {
if (args.dryRun) {
console.log(` [dry-run] would write ${p}`);
return;
}
fs.writeFileSync(abs(p), content);
console.log(` wrote ${p}`);
}
function deletePath(p, args) {
if (!fs.existsSync(abs(p))) return;
if (args.dryRun) {
console.log(` [dry-run] would delete ${p}`);
return;
}
fs.rmSync(abs(p), { recursive: true, force: true });
console.log(` deleted ${p}`);
}
function movePath(from, to, args) {
if (!fs.existsSync(abs(from))) return;
if (args.dryRun) {
console.log(` [dry-run] would move ${from} -> ${to}`);
return;
}
fs.mkdirSync(path.dirname(abs(to)), { recursive: true });
fs.renameSync(abs(from), abs(to));
console.log(` moved ${from} -> ${to}`);
}
function run(cmd, cmdArgs, args) {
if (args.dryRun) {
console.log(` [dry-run] would run: ${cmd} ${cmdArgs.join(' ')}`);
return;
}
console.log(` running: ${cmd} ${cmdArgs.join(' ')}`);
execFileSync(cmd, cmdArgs, { cwd: ROOT, stdio: 'inherit' });
}
const kebabCase = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const pascalCase = (name) =>
name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
// --- 1. strip business contexts ---------------------------------------------
function stripContexts(names, args) {
for (const name of names) {
deletePath(`src/app/${name}`, args);
pruneDependencyCruiser(name, args);
pruneTsconfig(name, args);
const removedPaths = pruneRoutes(name, args);
pruneNavLinks(removedPaths, args);
if (name === 'registratie') {
deletePath('src/app/shared/ui/debug-state', args);
patchShellComponent(args);
}
if (name === 'showcase') {
deletePath('scripts/gen-snippets.mjs', args);
pruneGenSnippetsWiring(args);
}
}
const keptCaps = pruneCapabilities(args);
syncMeAdapterKnown(keptCaps, args);
syncMeAdapterSpec(keptCaps, args);
pruneStoryCaps(keptCaps, args);
}
function pruneDependencyCruiser(name, args) {
const file = '.dependency-cruiser.js';
const content = readFile(file);
const re = new RegExp(`^\\s*${name}:\\s*(?:\\[[^\\]]*\\]|null),.*\\n`, 'm');
const next = content.replace(re, '');
if (next === content) {
console.log(` (no CONTEXT_ALLOWED entry for '${name}' in ${file} — already gone?)`);
return;
}
writeFile(file, next, args);
}
function pruneTsconfig(name, args) {
const file = 'tsconfig.json';
const content = readFile(file);
const re = new RegExp(`^\\s*"@${name}/\\*":\\s*\\["src/app/${name}/\\*"\\],\\n`, 'm');
const next = content.replace(re, '');
if (next === content) {
console.log(` (no @${name}/* alias in ${file} — already gone?)`);
return;
}
writeFile(file, next, args);
}
// Route children are 6-space-indented multi-line objects, e.g.:
// {
// path: 'registratie',
// ...
// },
// The two anchors that must never move — `{ path: '', ... }` and the `**` wildcard — are
// single-line (per plopfile.mjs's own "lines that never move" convention), so this pattern
// (which requires the closing `},` at the START of its own line) never matches them.
const ROUTE_BLOCK = /^ {6}\{\n[\s\S]*?\n {6}\},\n/gm;
/** Returns the `path:` values of every route block removed, so nav-link arrays elsewhere
(which reference routes by path string, not import alias) can be pruned to match. */
function pruneRoutes(name, args) {
const file = 'src/app/app.routes.ts';
const content = readFile(file);
// A route can reference a context by import alias regardless of its own `path:` — e.g.
// `beheer/zaken` imports `@registratie/ui/admin-cases.page`. Match on the import, not the
// route's own path segment.
const marker = name === 'showcase' ? `'./showcase/` : `@${name}/`;
const removedPaths = [];
const next = content.replace(ROUTE_BLOCK, (block) => {
if (!block.includes(marker)) return block;
if (block.includes(`path: 'dashboard'`)) return block; // handled by repointDashboard
const m = block.match(/path: '([^']+)'/);
if (m) removedPaths.push(m[1]);
return '';
});
if (!removedPaths.length) {
console.log(` (no ${file} route block referenced ${marker})`);
return removedPaths;
}
writeFile(file, next, args);
return removedPaths;
}
/** site-header.component.ts's NAV_ITEMS and admin-links.ts's ADMIN_LINKS reference routes by
path string, not import alias — so they don't get caught by pruneRoutes. Strip any entry
whose `to:` matches a route path that was just removed. */
function pruneNavLinks(removedPaths, args) {
if (!removedPaths.length) return;
const pathSet = new Set(removedPaths.map((p) => `/${p}`));
const headerFile = 'src/app/shared/layout/site-header/site-header.component.ts';
if (fs.existsSync(abs(headerFile))) {
const content = readFile(headerFile);
const next = content
.split('\n')
.filter((line) => {
const m = line.match(/to: '([^']+)'/);
return !(m && pathSet.has(m[1]));
})
.join('\n');
if (next !== content) writeFile(headerFile, next, args);
}
const adminFile = 'src/app/shared/layout/admin-links.ts';
if (fs.existsSync(abs(adminFile))) {
const content = readFile(adminFile);
const ADMIN_LINK_BLOCK = /^ {2}\{\n[\s\S]*?\n {2}\},\n/gm;
const next = content.replace(ADMIN_LINK_BLOCK, (block) => {
const toMatch = block.match(/to: '([^']+)'/);
return !toMatch || !pathSet.has(toMatch[1]) ? block : '';
});
if (next !== content) writeFile(adminFile, next, args);
}
}
/** Capability.ts's union members are plain string literals (no import), so a stripped
context's caps survive deletion silently. Rather than tracking which admin-link entry
"owned" which cap (a capability can gate more than one page — 'cases:manage' gates both
/beheer/zaken, which registratie owns, and /beheer/audit, which survives it), recompute
actual usage once every route/nav-link prune is done: a cap survives iff some remaining
file still references it as a string literal. Call once, after stripContexts' loop.
(Under --dry-run nothing was actually written above, so this reads pre-prune content —
an accepted approximation for a preview flag.) */
function pruneCapabilities(args) {
const file = 'src/app/shared/domain/capability.ts';
if (!fs.existsSync(abs(file))) return [];
const usageFiles = ['src/app/app.routes.ts', 'src/app/shared/layout/admin-links.ts'];
const usedCaps = new Set();
for (const f of usageFiles) {
if (!fs.existsSync(abs(f))) continue;
for (const m of readFile(f).matchAll(/'([a-z]+:[a-z]+)'/g)) usedCaps.add(m[1]);
}
const content = readFile(file);
const lines = content.split('\n');
const keptCaps = [];
const kept = lines.filter((line) => {
const m = line.match(/^\s*\|\s*'([^']+)'/);
if (!m) return true;
if (!usedCaps.has(m[1])) return false;
keptCaps.push(m[1]);
return true;
});
// Re-terminate the union type: only the last `|` line should carry the trailing `;`.
let lastIdx = -1;
for (let i = 0; i < kept.length; i++) {
if (/^\s*\|\s*'/.test(kept[i])) {
kept[i] = kept[i].replace(/;\s*$/, '');
lastIdx = i;
}
}
if (lastIdx >= 0) kept[lastIdx] += ';';
const next = kept.join('\n');
if (next !== content) writeFile(file, next, args);
return keptCaps;
}
/** me.adapter.ts's KNOWN array is documented as "the current principal's capabilities" — by
definition the same set Capability allows, so regenerate it to match exactly rather than
treating it as an independent usage site (it would otherwise keep now-invalid literals a
plain string-literal array doesn't get flagged for by the type checker until `KNOWN` is
actually assigned, which it is — `readonly Capability[]` — so this is build-breaking, not
cosmetic, if left stale). */
function syncMeAdapterKnown(keptCaps, args) {
const file = 'src/app/shared/infrastructure/me.adapter.ts';
if (!fs.existsSync(abs(file))) return;
const content = readFile(file);
const next = content.replace(
/const KNOWN: readonly Capability\[\] = \[\n[\s\S]*?\n\];/,
`const KNOWN: readonly Capability[] = [\n${keptCaps.map((c) => ` '${c}',`).join('\n')}\n];`,
);
if (next !== content) writeFile(file, next, args);
}
/** me.adapter.spec.ts hardcodes example capability strings as test fixtures (not typed
against Capability, so tsc/lint don't catch drift — only actually running the suite
surfaces it, as a plain assertion failure). Retarget the stale ones at surviving caps
(cycling through keptCaps so a multi-example test still gets distinct values) and drop
the one test that's about a capability tied entirely to a stripped feature. */
function syncMeAdapterSpec(keptCaps, args) {
const file = 'src/app/shared/infrastructure/me.adapter.spec.ts';
if (!fs.existsSync(abs(file)) || !keptCaps.length) return;
let content = readFile(file);
content = content.replace(
/\n {2}it\('recognizes the admin org-template capability[\s\S]*?\n {2}\}\);\n/,
'\n',
);
const stale = ['brief:approve', 'brief:reject', 'brief:send', 'orgtemplate:edit'];
stale.forEach((s, i) => {
content = content.replaceAll(`'${s}'`, `'${keptCaps[i % keptCaps.length]}'`);
});
writeFile(file, content, args);
}
/** site-header.stories.ts fixtures a couple of admin caps by hand for its "with admin nav"
story — filter out any that no longer exist, same reasoning as syncMeAdapterKnown. */
function pruneStoryCaps(keptCaps, args) {
const file = 'src/app/shared/layout/site-header/site-header.stories.ts';
if (!fs.existsSync(abs(file))) return;
const keptSet = new Set(keptCaps);
const content = readFile(file);
const next = content.replace(/withCaps\(\[([^\]]*)\]\)/g, (whole, inner) => {
const kept = [...inner.matchAll(/'([^']+)'/g)].map((m) => m[1]).filter((c) => keptSet.has(c));
return `withCaps([${kept.map((c) => `'${c}'`).join(', ')}])`;
});
if (next !== content) writeFile(file, next, args);
}
function patchShellComponent(args) {
const file = 'src/app/shared/layout/shell/shell.component.ts';
const content = readFile(file);
const next = content
.replace(/^import \{ DebugStateComponent \}.*\n/m, '')
.replace(/^\s*DebugStateComponent,\n/m, '')
.replace(/^\s*@if \(isDev\) \{\n\s*<app-debug-state \/>\n\s*\}\n/m, '');
writeFile(file, next, args);
}
function pruneGenSnippetsWiring(args) {
const pkgPath = 'package.json';
const pkg = JSON.parse(readFile(pkgPath));
delete pkg.scripts['gen:snippets'];
writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', args);
for (const file of ['.github/workflows/ci.yml', 'scripts/ci-local.sh']) {
const content = readFile(file);
const next = content
.replace(/^\s*#.*[Ss]howcase snippets.*\n/m, '')
.replace(/^.*npm run gen:snippets.*\n/m, '');
writeFile(file, next, args);
}
}
// --- 2. rename BigRegister -> <Name> -----------------------------------------
const RENAME_CONTENT_FILES = [
'docker-compose.yml',
'package.json',
'.github/workflows/ci.yml',
'playwright.config.ts',
'README.md',
'backend/README.md',
];
const SKIP_DIRS = new Set(['bin', 'obj', 'node_modules', '.git']);
/** Recursively rename any BigRegister-named file/dir and replace BigRegister in file content. */
function renameAndReplaceInTree(dirRel, name, args) {
const dirAbs = abs(dirRel);
if (!fs.existsSync(dirAbs)) return;
for (const entry of fs.readdirSync(dirAbs, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const childRel = path.join(dirRel, entry.name);
if (entry.isDirectory()) {
renameAndReplaceInTree(childRel, name, args);
continue;
}
let fileRel = childRel;
if (entry.name.includes('BigRegister')) {
const renamed = path.join(dirRel, entry.name.replaceAll('BigRegister', name));
movePath(fileRel, renamed, args);
fileRel = renamed;
}
if (args.dryRun) continue;
const content = fs.readFileSync(abs(fileRel), 'utf8');
if (content.includes('BigRegister')) {
writeFile(fileRel, content.replaceAll('BigRegister', name), args);
}
}
}
function renameBigRegister(name, args) {
const kebab = kebabCase(name);
for (const file of RENAME_CONTENT_FILES) {
if (!fs.existsSync(abs(file))) continue;
const content = readFile(file);
const next = content.replaceAll('BigRegister', name).replaceAll('bigregister', kebab);
if (next !== content) writeFile(file, next, args);
}
movePath('backend/BigRegister.slnx', `backend/${name}.slnx`, args);
movePath('backend/src/BigRegister.Api', `backend/src/${name}.Api`, args);
movePath('backend/tests/BigRegister.Tests', `backend/tests/${name}.Tests`, args);
if (!args.dryRun) {
for (const dir of [`backend/src/${name}.Api`, `backend/tests/${name}.Tests`]) {
renameAndReplaceInTree(dir, name, args);
}
const slnx = `backend/${name}.slnx`;
if (fs.existsSync(abs(slnx))) {
const content = readFile(slnx);
writeFile(slnx, content.replaceAll('BigRegister', name), args);
}
}
}
// --- 3/4. checklists (deliberately not scripted — see WP-45 plan) -----------
function backendChecklist(name, checklist) {
checklist.push(
`Backend Domain/Contracts/Data content is BIG-register business logic and can't be ` +
`auto-generated for a new register (renamed to ${name}.* only, content untouched). ` +
`Rewrite, verifying \`dotnet test\` stays green after each step:`,
" 1. Contracts/Dtos.cs + Mappers.cs -> your register's wire shapes",
' 2. Domain/{Diplomas,Documents,Intake,Letters,People,Registrations,Submissions}/* ' +
"-> your register's rules (Stamdata/StamdataFile.cs + StamdataTable.cs stay generic)",
' 3. Stamdata/{Beroep,Opleiding,Specialisme,ProfessionMapping}.cs + their *.json ' +
'-> your reference data; update StamdataCatalog.All to match',
' 4. Data/SeedData.cs -> fixtures for the new Domain/* shapes',
" 5. Zgw/ -> delete if you don't integrate with OpenZaak/ZGW, else adapt",
` 6. tests/${name}.Tests/* -> mostly assert BIG-specific rules today; treat as a shape ` +
'reference (WebApplicationFactory harness, ProblemDetails assertions), rewrite content',
);
console.log(' (printed to the final checklist — not scriptable)');
}
function brandingChecklist(args, checklist) {
const kebab = kebabCase(args.name);
const file = 'src/index.html';
const content = readFile(file);
const next = content
.replace(
/<link rel="stylesheet" href="cibg-huisstijl\/css\/huisstijl\.min\.css" \/>/,
`<link rel="stylesheet" href="${kebab}-huisstijl/css/huisstijl.min.css" />`,
)
.replace(/<title>.*<\/title>/, `<title>${args.name}</title>`)
.replace(' class="brand--cibg"', '');
writeFile(file, next, args);
const placeholder = `public/${kebab}-huisstijl/css/huisstijl.min.css`;
if (!args.dryRun) {
fs.mkdirSync(path.dirname(abs(placeholder)), { recursive: true });
if (!fs.existsSync(abs(placeholder))) {
fs.writeFileSync(abs(placeholder), '/* placeholder — vendor your real house style here */\n');
}
}
console.log(` wrote placeholder ${placeholder}`);
checklist.push(
`Branding is only placeholder-swapped (${file}'s <link>, <title>). Vendor your real ` +
`house-style CSS into public/${kebab}-huisstijl/, then re-point the ~54 --rhc-* ` +
"token definitions in src/styles.scss's :root block to your palette (ADR-0003 bridge " +
'pattern — keep the --rhc-* names, only their right-hand values change). Run ' +
'`npm run check:tokens` afterward.',
);
}
// --- 6. re-point the dashboard placeholder after gen:context runs -----------
function repointDashboard(contextName, registratieWasStripped, args) {
if (!registratieWasStripped) return;
const file = 'src/app/app.routes.ts';
const content = readFile(file);
const pageClass = `${pascalCase(contextName)}Page`;
const next = content.replace(
/loadComponent: \(\) => import\('@registratie\/ui\/dashboard\.page'\)\.then\(\(m\) => m\.DashboardPage\),/,
`// TODO(create-ssp): stopgap landing page — point this at a real overview once you have one.\n` +
` loadComponent: () =>\n` +
` import('@${contextName}/ui/${contextName}.page').then((m) => m.${pageClass}),`,
);
if (next === content) {
console.log(
` (dashboard route's loadComponent didn't match the expected pattern — check ${file} by hand)`,
);
return;
}
writeFile(file, next, args);
}
// --- checklist + main --------------------------------------------------------
function printChecklist(checklist, args) {
section('Next steps (not scriptable — read carefully)');
for (const item of checklist) console.log(item);
console.log(
'\nAlso update docs/CLAUDE.md, ARCHITECTURE.md, docs/reference/scaffolding.md, and ' +
'e2e/*.spec.ts (still BIG-register user-flow tests) once the backend content above ' +
'is real. Then run `npm run ci` end-to-end.',
);
if (args.dryRun) console.log('\n(--dry-run: nothing above was actually written.)');
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.name || !args.context) usageAndExit('--name and --context are required.');
const checklist = [];
const toStrip = ALL_CONTEXTS.filter((c) => c !== args.keep);
section('1/6 Strip business contexts');
stripContexts(toStrip, args);
section(`2/6 Rename BigRegister -> ${args.name}`);
renameBigRegister(args.name, args);
section('3/6 Backend re-seed (manual)');
backendChecklist(args.name, checklist);
section('4/6 Branding (mostly manual)');
brandingChecklist(args, checklist);
section('5/6 Regenerate API client');
if (args.skipBackend) {
console.log(' skipped (--skip-backend)');
checklist.push('Run `npm run gen:api` once a .NET SDK is available.');
} else {
run('npm', ['run', 'gen:api'], args);
}
section('6/6 Seed first context via gen:context');
run('npx', ['plop', 'context', args.context], args);
repointDashboard(args.context, toStrip.includes('registratie'), args);
printChecklist(checklist, args);
}
main();
@@ -149,3 +149,15 @@ export const WithDiff: Story = {
export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
};
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
const sampleLogo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */
export const MetLogo: Story = {
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
};
@@ -3,6 +3,7 @@ import { LetterComposerComponent } from './letter-composer.component';
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BlockDiffKind } from '@brief/domain/brief-diff';
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
@@ -117,17 +118,29 @@ function brief(status: BriefStatus): Brief {
};
}
const render = (b: Brief, decisions: BriefDecisions) => ({
const render = (
b: Brief,
decisions: BriefDecisions,
extra: {
blockDiffs?: ReadonlyMap<string, BlockDiffKind>;
removedCount?: number;
logoUrl?: string | null;
} = {},
) => ({
props: {
brief: b,
orgTemplate,
diagnostics: allDiagnostics(b),
...decisions,
busy: false,
blockDiffs: extra.blockDiffs ?? new Map<string, BlockDiffKind>(),
removedCount: extra.removedCount ?? 0,
logoUrl: extra.logoUrl ?? null,
},
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate"
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [logoUrl]="logoUrl"
[diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
[canSend]="canSend" [busy]="busy"></app-letter-composer>`,
[canSend]="canSend" [busy]="busy" [blockDiffs]="blockDiffs"
[removedCount]="removedCount"></app-letter-composer>`,
});
const meta: Meta<LetterComposerComponent> = {
@@ -167,3 +180,43 @@ export const Sent: Story = {
canRevealBigNummer: false,
}),
};
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
added and removed since the last rejection. */
export const RejectionDiff: Story = {
render: () =>
render(
brief({
tag: 'submitted',
submittedBy: 'demo-drafter',
submittedAt: '2026-07-02',
}),
{
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
canRevealBigNummer: false,
},
{
blockDiffs: new Map<string, BlockDiffKind>([
['local-2', 'changed'],
['local-3', 'added'],
]),
removedCount: 1,
},
),
};
/** A pure viewer (e.g. admin) has no approve/reject/send capability on this letter —
the read-only notice, not a broken-looking editor. */
export const AlleenLezen: Story = {
render: () =>
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
canEdit: false,
canApprove: false,
canReject: false,
canSend: false,
canRevealBigNummer: false,
}),
};
@@ -79,3 +79,26 @@ export const Invalid: Story = {
export const NoHistory: Story = {
args: { history: [], publishedVersion: 0 },
};
// Inline SVG so the story needs no backend/upload round-trip.
const sampleLogo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */
export const MetLogo: Story = {
args: { logoUrl: sampleLogo },
};
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught
before the file ever reaches the backend. */
export const LogoUploadFout: Story = {
args: {
uploadState: {
...uploadWithCategory,
rejections: { 'org-logo': 'Alleen PNG of JPEG, maximaal 2 MB.' },
},
},
};
@@ -8,7 +8,11 @@ import { LibraryPassage } from '@brief/domain/brief';
/** Molecule: multi-select list of the section's library passages. One "Voeg toe"
inserts ALL checked passages at once (a single message upstream) — there is no
single-insert path. Presentational: emits the chosen passages in list order. */
single-insert path. Presentational: emits the chosen passages in list order.
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted
in-flight of an unrelated WP; a future cleanup can remove it. */
@Component({
selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
@@ -1,4 +1,7 @@
import { Component, computed, input } from '@angular/core';
import { Component, computed, inject, input } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { EMPTY, filter } from 'rxjs';
import { Locale, localeLinks } from './locale-links';
// CIBG-GAP EXTENSION: "Taal instellen" (designsystem.cibg.nl/componenten/taal-instellen) — no
@@ -14,6 +17,11 @@ import { Locale, localeLinks } from './locale-links';
* is read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent
* of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the
* localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).
*
* The shell (and this switcher within it) is a persistent parent — only the routed child
* swaps — so `location.pathname` must be re-read on every completed navigation (same
* `toSignal(router.events...)` idiom as `site-header.component.ts`'s breadcrumb `url`), or the
* target link freezes at whichever route was active when the switcher was first constructed.
*/
@Component({
selector: 'app-language-switcher',
@@ -68,14 +76,21 @@ export class LanguageSwitcherComponent {
? location
: ({ pathname: '/', search: '', hash: '' } as Location);
protected links = computed(() =>
localeLinks(
private router = inject(Router, { optional: true });
private nav = toSignal(
this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,
{ initialValue: null },
);
protected links = computed(() => {
this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below
return localeLinks(
this.loc.pathname,
this.activeLocale() ?? this.detected,
this.loc.search,
this.loc.hash,
),
);
);
});
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
protected heading = $localize`:@@lang.heading:Kies een taal`;