Compare commits
7
Commits
7ef8ac7409
...
abc4728c97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abc4728c97 | ||
|
|
ad7ca31841 | ||
|
|
e4022fd31a | ||
|
|
e7e2f070f9 | ||
|
|
94cd0b82c2 | ||
|
|
67170fbc84 | ||
|
|
7b6cabfc4a |
@@ -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."
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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'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.</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'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.</p>\n<p>Superseded by <code>besluit-panel</code> (WP-27'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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
+74
-1
@@ -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.',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user