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
|
## Steps
|
||||||
|
|
||||||
1. **Folders** — `src/app/<ctx>/{domain,application,infrastructure,ui}` (`contracts/`
|
1. Run `npm run gen:context` (WP-44) and give it the lowercase context name. It
|
||||||
only once it gets a wire seam). Empty layers can wait; don't scaffold placeholders.
|
mechanises the manual edits below in one shot:
|
||||||
2. **Path alias** — add `"@<ctx>/*": ["src/app/<ctx>/*"]` to `tsconfig.json` `paths`.
|
- Folders — `src/app/<ctx>/{domain,application,infrastructure,contracts}/.gitkeep` +
|
||||||
Aliases are direction statements; always import cross-context via the alias.
|
a starter `ui/<ctx>.page.ts` (replace with the real first feature).
|
||||||
3. **Boundaries** (`.dependency-cruiser.js`, WP-38 — the single declarative source;
|
- Path alias — `"@<ctx>/*": ["src/app/<ctx>/*"]` added to `tsconfig.json` `paths`.
|
||||||
dependencies point inward and toward `shared` only). Add ONE `contextRule(...)` entry
|
- Boundary entry — one new key in `.dependency-cruiser.js`'s `CONTEXT_ALLOWED` map
|
||||||
for the new context listing the contexts it may **not** import (copy the `brief` leaf
|
(WP-38's single declarative source; every context's forbidden list is _derived_
|
||||||
example), and add the new context to the forbidden list of any context that must not
|
from that map, so adding one key is enough — nothing else to hand-edit). List the
|
||||||
depend on it. The layer rules (`domain/` framework-free, `contracts/` import-nothing,
|
OTHER contexts the new one may import (usually `[]` — a leaf, like `brief`).
|
||||||
ApiClient confinement, `ui ↛ infrastructure`) match by glob and cover it automatically.
|
- Route — a lazy child under the persistent shell in `app.routes.ts`, gated by
|
||||||
Verify with `npm run dep:check`; regenerate the graph with `npm run dep:graph`. (Boundaries
|
`authGuard`.
|
||||||
are no longer in `eslint.config.mjs` — that now holds only `no-explicit-any` + template a11y.)
|
2. Verify: `npm run dep:check && npm run lint && npm run build` (regenerate the graph
|
||||||
4. **Route** — lazy child under the persistent shell in `app.routes.ts`:
|
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
|
||||||
```ts
|
generated placeholder page.
|
||||||
{ path: '<ctx>', canActivate: [authGuard],
|
|
||||||
loadComponent: () => import('@<ctx>/ui/<ctx>.page').then(m => m.CtxPage) }
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Build the first feature slice with the **new-feature** skill.
|
|
||||||
|
|
||||||
## Worked example
|
## 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
|
a11y gate, API-drift gate) and the shared building blocks — not the BIG-register
|
||||||
business content. Keep the machinery, replace the domain.
|
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
|
## Keep as-is
|
||||||
|
|
||||||
- `src/app/shared/` — kernel (`fp.ts`), application (`remote-data`, `store`,
|
- `src/app/shared/` — kernel (`fp.ts`), application (`remote-data`, `store`,
|
||||||
`submit`), ui atoms/molecules, layout templates, upload subtree.
|
`submit`), ui atoms/molecules, layout templates, upload subtree — **except**
|
||||||
- Tooling: `eslint.config.mjs`, `scripts/check-tokens.sh`, `.github/workflows/ci.yml`,
|
`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` —
|
`nswag.json`, `.storybook/`, `proxy.conf.json`, `.npmrc` (`legacy-peer-deps` —
|
||||||
and never `npm audit fix --force`, it downgrades Angular).
|
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).
|
- `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.
|
- `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.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.
|
- `.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/`:
|
- Business contexts `registratie/`, `herregistratie/`, `brief/`, `showcase/`: deleted (or one
|
||||||
delete or keep one slice temporarily as the worked example while building the
|
kept temporarily via `--keep` as the worked example while building the first real context —
|
||||||
first real context (**new-context** + **new-feature** skills). If deleted, update
|
**new-context** + **new-feature** skills; if kept, update the worked-example paths in those
|
||||||
the worked-example paths in these skills to the new flagship context.
|
skills to the new flagship context once you drop it for real).
|
||||||
- `app.routes.ts` routes and `tsconfig.json` aliases for removed contexts, plus
|
- `app.routes.ts` route blocks and `tsconfig.json` aliases for removed contexts, and their
|
||||||
their eslint blocks in `eslint.config.mjs`.
|
`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,
|
- Backend: keep the skeleton (`Program.cs` minimal-API style, ProblemDetails 422,
|
||||||
`X-Correlation-Id` audit line, `/api/v1` versioning, `Contracts/`/`Domain/`/`Data/`
|
`X-Correlation-Id` audit line, `/api/v1` versioning, `Contracts/`/`Domain/`/`Data/`
|
||||||
split, test project) — replace `Data/SeedData.cs`, `Domain/*` rules, and
|
split, test project, and the generic `Stamdata/StamdataFile.cs`+`StamdataTable.cs`
|
||||||
`Contracts/*` DTOs with the new domain's. Rename the solution/projects from
|
reflection-driven `/stamdata` endpoint machinery, ADR-0004) — replace `Data/SeedData.cs`,
|
||||||
`BigRegister.*` (also update `package.json` `gen:api` and `ci.yml` paths).
|
`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` +
|
- Regenerate the seam: `npm run gen:api` (commits `backend/swagger.json` +
|
||||||
`src/app/shared/infrastructure/api-client.ts`).
|
`src/app/shared/infrastructure/api-client.ts`) — only reflects a new shape once the backend
|
||||||
- Branding: `public/cibg-huisstijl/` + the token bridge in `src/styles.scss` — for a
|
content above is actually rewritten.
|
||||||
different house style, swap the vendored CSS and re-point the `--rhc-*` bridge
|
- Branding: the script swaps `src/index.html`'s stylesheet `<link>` + `<title>` to a
|
||||||
(ADR-0003 pattern: bridge, don't rewrite tokens).
|
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
|
- `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).
|
(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
|
## 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
|
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
|
Or just `npm run ci` for the non-storybook subset. Strip incrementally and keep this green
|
||||||
never go red.
|
— 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 → *
|
// Allowed cross-context edges: everyone → shared; herregistratie → registratie; showcase → *
|
||||||
// (the sanctioned teaching page). Nobody imports 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 FEATURES = Object.keys(CONTEXT_ALLOWED).join('|');
|
||||||
const contextRule = (name, from, forbiddenContexts) => ({
|
|
||||||
name,
|
/** A context may import shared + itself + its allowed list; forbidden = every other context. */
|
||||||
comment: `${from} may depend only on its allowed contexts (+ shared). See CLAUDE.md §1.`,
|
const contextRule = (from) => {
|
||||||
severity: 'error',
|
const allowed = CONTEXT_ALLOWED[from];
|
||||||
from: { path: `^src/app/${from}/` },
|
if (allowed === null) return null; // unrestricted (showcase) — no rule to generate
|
||||||
to: { path: `^src/app/(${forbiddenContexts})/` },
|
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 = {
|
module.exports = {
|
||||||
forbidden: [
|
forbidden: [
|
||||||
@@ -29,16 +50,7 @@ module.exports = {
|
|||||||
from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' },
|
from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' },
|
||||||
to: { path: `^src/app/(${FEATURES})/` },
|
to: { path: `^src/app/(${FEATURES})/` },
|
||||||
},
|
},
|
||||||
contextRule('auth-only-shared', 'auth', 'registratie|herregistratie|brief|beheer|showcase'),
|
...Object.keys(CONTEXT_ALLOWED).map(contextRule).filter(Boolean),
|
||||||
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'),
|
|
||||||
// showcase/ is exempt (reads every context by design); nothing imports it — covered by the
|
// showcase/ is exempt (reads every context by design); nothing imports it — covered by the
|
||||||
// rules above each forbidding `→ showcase`.
|
// 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
|
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.
|
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)
|
## The decisions (non-negotiable working agreements)
|
||||||
|
|
||||||
### 1. DDD: contexts then layers, dependencies point inward
|
### 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
|
**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.
|
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`
|
- 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).
|
- 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`,
|
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
||||||
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
||||||
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
|
- **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. |
|
| [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/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/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. |
|
| [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
|
- **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.
|
"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
|
- **Match the model to the step, not the whole session** (see CLAUDE.md's "Model routing
|
||||||
opus`) — a WP's approach and edge cases deserve the stronger model. Switch to Sonnet to
|
for agent delegation"). Read the WP's Decisions block with the `planner` agent (Opus) if
|
||||||
write the code once the plan is approved — routine implementation against a pre-made
|
the current session isn't already Opus — the approach and edge cases deserve the
|
||||||
Decisions block doesn't need Opus. Delegate simple, read-only CLI checks (running a test
|
stronger model. Implement directly if already on Sonnet, or hand off to the `developer`
|
||||||
suite, `grep`/`git status`, verifying a file exists) to a Haiku subagent where the harness
|
agent otherwise, once the plan is approved. Delegate simple, read-only CLI checks
|
||||||
supports it — proportion cost to the step's difficulty, not the WP's.
|
(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.
|
- 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
|
- A WP ends **GREEN** (below) with its acceptance criteria checked off and its Status
|
||||||
updated to `done` (+ commit hash).
|
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-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-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-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-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-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 |
|
| [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-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-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-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-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 | todo |
|
| [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-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-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 |
|
| [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)
|
# 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)
|
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -45,12 +45,53 @@ that keep CLAUDE.md and the backlog truthful.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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).
|
(walked manually once).
|
||||||
- [ ] `npm run e2e` green, including the new spec.
|
- [ ] `npm run e2e` green, including the new spec — **not verified in this dev
|
||||||
- [ ] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
|
sandbox**; see Deviations.
|
||||||
|
- [x] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
|
||||||
`?role=admin` and `/brief/huisstijl`.
|
`?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
|
## Verification
|
||||||
|
|
||||||
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
|
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
# WP-44 — `gen:context` generator
|
# WP-44 — `gen:context` generator
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 8 — platform/DX/showcase
|
Phase: 8 — platform/DX/showcase
|
||||||
Priority: P3
|
Priority: P3
|
||||||
Depends on: WP-38, WP-43
|
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
|
## Why
|
||||||
|
|
||||||
Adding a bounded context is currently a manual multi-file edit (folders + tsconfig alias + copied
|
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
|
## 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.
|
routes lazily.
|
||||||
- [ ] Boundary tool (WP-38) validates the new context's allowed edges.
|
- [x] Boundary tool (WP-38) validates the new context's allowed edges.
|
||||||
- [ ] `npm run ci` green.
|
- [x] `npm run ci` green.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-45 — `create-ssp` bootstrap generator
|
# WP-45 — `create-ssp` bootstrap generator
|
||||||
|
|
||||||
Status: todo
|
Status: done (ad7ca31)
|
||||||
Phase: 8 — platform/DX/showcase
|
Phase: 8 — platform/DX/showcase
|
||||||
Priority: P4
|
Priority: P4
|
||||||
Depends on: WP-43, WP-44
|
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",
|
"name": "PassagePickerComponent",
|
||||||
"id": "component-PassagePickerComponent-25156c4d1008c23952424dd87e6d92081346f0c822913b8ba0921f630399914d5c0ef8af5b5dfe67be1f8a9486b92e35c5115cf66f442cbb7e9a21a0351280aa",
|
"id": "component-PassagePickerComponent-ba7eccc65c72f5ec20ce8442c142a6c4eb63d430727d8e470a493162d579c1e8a0236b9927d38d75bea4a0246ff2f5afbf7ce996eaf3fd89f4fa0fc28a2c6857",
|
||||||
"file": "src/app/brief/ui/passage-picker/passage-picker.component.ts",
|
"file": "src/app/brief/ui/passage-picker/passage-picker.component.ts",
|
||||||
"encapsulation": [],
|
"encapsulation": [],
|
||||||
"entryComponents": [],
|
"entryComponents": [],
|
||||||
@@ -28669,7 +28669,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 77,
|
"line": 81,
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -28680,7 +28680,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 79,
|
"line": 83,
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -28691,7 +28691,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 78,
|
"line": 82,
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -28702,7 +28702,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 81,
|
"line": 85,
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -28713,7 +28713,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 74,
|
"line": 78,
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -28724,7 +28724,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 80,
|
"line": 84,
|
||||||
"required": false
|
"required": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -28737,7 +28737,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 75,
|
"line": 79,
|
||||||
"required": false
|
"required": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -28751,7 +28751,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 83,
|
"line": 87,
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
124
|
124
|
||||||
]
|
]
|
||||||
@@ -28765,7 +28765,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 94,
|
"line": 98,
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
124
|
124
|
||||||
]
|
]
|
||||||
@@ -28779,7 +28779,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"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",
|
"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`).",
|
"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": [
|
"modifierKind": [
|
||||||
124
|
124
|
||||||
@@ -28794,7 +28794,7 @@
|
|||||||
"indexKey": "",
|
"indexKey": "",
|
||||||
"optional": false,
|
"optional": false,
|
||||||
"description": "",
|
"description": "",
|
||||||
"line": 84,
|
"line": 88,
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
124
|
124
|
||||||
]
|
]
|
||||||
@@ -28807,7 +28807,7 @@
|
|||||||
"optional": false,
|
"optional": false,
|
||||||
"returnType": "void",
|
"returnType": "void",
|
||||||
"typeParameters": [],
|
"typeParameters": [],
|
||||||
"line": 100,
|
"line": 104,
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"deprecationMessage": "",
|
"deprecationMessage": "",
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
@@ -28837,7 +28837,7 @@
|
|||||||
"optional": false,
|
"optional": false,
|
||||||
"returnType": "void",
|
"returnType": "void",
|
||||||
"typeParameters": [],
|
"typeParameters": [],
|
||||||
"line": 96,
|
"line": 100,
|
||||||
"deprecated": false,
|
"deprecated": false,
|
||||||
"deprecationMessage": "",
|
"deprecationMessage": "",
|
||||||
"modifierKind": [
|
"modifierKind": [
|
||||||
@@ -28892,10 +28892,10 @@
|
|||||||
"type": "component"
|
"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",
|
"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.",
|
"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",
|
"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": [],
|
"assetsDirs": [],
|
||||||
"styleUrlsData": "",
|
"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",
|
"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.",
|
"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"
|
"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",
|
"name": "initial",
|
||||||
"ctype": "miscellaneous",
|
"ctype": "miscellaneous",
|
||||||
@@ -34468,26 +34488,6 @@
|
|||||||
"type": "OrgTemplateState",
|
"type": "OrgTemplateState",
|
||||||
"defaultValue": "{ tag: 'loading' }"
|
"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",
|
"name": "initial",
|
||||||
"ctype": "miscellaneous",
|
"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",
|
"name": "reduce",
|
||||||
"file": "src/app/beheer/domain/stamdata-editor.machine.ts",
|
"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",
|
"name": "reduce",
|
||||||
"file": "src/app/registratie/domain/change-request.machine.ts",
|
"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}"
|
"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": [
|
"src/app/herregistratie/domain/herregistratie.machine.ts": [
|
||||||
{
|
{
|
||||||
"name": "initial",
|
"name": "initial",
|
||||||
@@ -44492,6 +44468,30 @@
|
|||||||
"description": "<p>The fixed step list. Number of steps never changes; questions reveal inline.</p>\n"
|
"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": [
|
"src/app/registratie/domain/change-request.machine.ts": [
|
||||||
{
|
{
|
||||||
"name": "initial",
|
"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": "plop",
|
||||||
"gen:value-object": "plop value-object",
|
"gen:value-object": "plop value-object",
|
||||||
"gen:form-machine": "plop form-machine",
|
"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",
|
"serve:i18n": "ng build --configuration development --localize && node scripts/serve-i18n.mjs",
|
||||||
"ci": "bash scripts/ci-local.sh",
|
"ci": "bash scripts/ci-local.sh",
|
||||||
"e2e": "playwright test",
|
"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
|
* 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 —
|
* 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
|
* 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`.
|
* Positional args skip the prompts, e.g. `npx plop value-object registratie KvkNummer`.
|
||||||
*/
|
*/
|
||||||
@@ -60,4 +61,76 @@ export default function (plop) {
|
|||||||
remindEnXlf,
|
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 = {
|
export const PageBreak: Story = {
|
||||||
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
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 { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||||
import { allDiagnostics } from '@brief/domain/brief';
|
import { allDiagnostics } from '@brief/domain/brief';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
|
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||||
|
|
||||||
const orgTemplate: OrgTemplate = {
|
const orgTemplate: OrgTemplate = {
|
||||||
subOrgId: 'cibg-registers',
|
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: {
|
props: {
|
||||||
brief: b,
|
brief: b,
|
||||||
orgTemplate,
|
orgTemplate,
|
||||||
diagnostics: allDiagnostics(b),
|
diagnostics: allDiagnostics(b),
|
||||||
...decisions,
|
...decisions,
|
||||||
busy: false,
|
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"
|
[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> = {
|
const meta: Meta<LetterComposerComponent> = {
|
||||||
@@ -167,3 +180,43 @@ export const Sent: Story = {
|
|||||||
canRevealBigNummer: false,
|
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 = {
|
export const NoHistory: Story = {
|
||||||
args: { history: [], publishedVersion: 0 },
|
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"
|
/** 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
|
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({
|
@Component({
|
||||||
selector: 'app-passage-picker',
|
selector: 'app-passage-picker',
|
||||||
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
|
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';
|
import { Locale, localeLinks } from './locale-links';
|
||||||
|
|
||||||
// CIBG-GAP EXTENSION: "Taal instellen" (designsystem.cibg.nl/componenten/taal-instellen) — no
|
// 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
|
* 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
|
* 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 `/`).
|
* 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({
|
@Component({
|
||||||
selector: 'app-language-switcher',
|
selector: 'app-language-switcher',
|
||||||
@@ -68,14 +76,21 @@ export class LanguageSwitcherComponent {
|
|||||||
? location
|
? location
|
||||||
: ({ pathname: '/', search: '', hash: '' } as Location);
|
: ({ pathname: '/', search: '', hash: '' } as Location);
|
||||||
|
|
||||||
protected links = computed(() =>
|
private router = inject(Router, { optional: true });
|
||||||
localeLinks(
|
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.loc.pathname,
|
||||||
this.activeLocale() ?? this.detected,
|
this.activeLocale() ?? this.detected,
|
||||||
this.loc.search,
|
this.loc.search,
|
||||||
this.loc.hash,
|
this.loc.hash,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
|
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
|
||||||
protected heading = $localize`:@@lang.heading:Kies een taal`;
|
protected heading = $localize`:@@lang.heading:Kies een taal`;
|
||||||
|
|||||||
Reference in New Issue
Block a user