Adopt everything Angular 22 and Nx 23 offer across spikersoft-angular. The runtime upgrade is done (22.0.6 / 23.1.0); this epic is adoption. Inventory confirmed we're already zoneless + 100% OnPush + control-flow-migrated + inject()-everywhere — the real gaps and the effort series:
Track Angular
E0 Housekeeping: stale files, strip vestigial SSR targets, pin paramsInheritanceStrategy: 'always', ngClass/ngStyle schematics, drop unused zone.js dep, re-include 5 lint-excluded projects
E3 Signal queries/IO: @ViewChild×73 / @Input×14 / @Output×14 / constructor-DI×6 via official v22 schematics
E8 optional: Angular Aria spike; @Service for new services
Track Nx (fully parallel)
E1a nx affected for CI test/lint (PRs; keep full run-many on master pushes; keep single full prod build per #549)
E1b self-hosted remote cache: Nx 23 built-in HTTP cache protocol (NX_SELF_HOSTED_REMOTE_CACHE_SERVER) + MinIO-backed cache server on the swarm, token in OpenBao. NOT nx-remotecache-* (dead API on 23), NOT @nx/s3-cache (activation key), never Nx Cloud
E1c e2e becomes an Nx project via @nx/playwright/plugin inference (wraps in place, cache:false — API-connected suite)
E2a local workspace generator for the 7-file lib pattern (test target + tags always emitted) + cached per-project typecheck targets (retires the unusable root tsc, fixes#664 pain)
E2b test-target backfill on the 30 libs without one
Conflict rules: keep @angular/build:unit-test (never @nx/vite:test); @nx/playwright is the ONLY inference plugin; never mix formControlName and [formField] in one form; per-directory schematic runs only.
~35–38 PRs total. Plan of record: ~/.claude/plans/bright-dazzling-ritchie.md (approved 2026-07-19).
Adopt everything Angular 22 and Nx 23 offer across spikersoft-angular. The runtime upgrade is done (22.0.6 / 23.1.0); this epic is adoption. Inventory confirmed we're already zoneless + 100% OnPush + control-flow-migrated + inject()-everywhere — the real gaps and the effort series:
**Track Angular**
- E0 Housekeeping: stale files, strip vestigial SSR targets, pin `paramsInheritanceStrategy: 'always'`, ngClass/ngStyle schematics, drop unused zone.js dep, re-include 5 lint-excluded projects
- E3 Signal queries/IO: `@ViewChild`×73 / `@Input`×14 / `@Output`×14 / constructor-DI×6 via official v22 schematics
- E4 resource()/httpResource(): 17 hand-rolled fetch-into-signal services (pilot: status.service; largest: art-studio.service). Rule: reads→resources, mutations imperative + reload()
- E5 Signal Forms (flagship): pilot contact.component proves `[formField]` + Material 22 error tracking + shared Transloco error helper; wave 1 = 9 Reactive surfaces small→large (profile last); wave 2 = ALL 63 ngModel screens (decision: migrate everything). MatStepper: drop [stepControl] for completed/editable computeds. ngx-mask files (3) stay Reactive until supported
- E6 @defer/injectAsync: three.js, Monaco, maps, emulators clusters (~178 heavy components; only 4 files use @defer today)
- E7 Router+HTTP: withComponentInputBinding; functional interceptors (order-preserving); withXhr stays (#230 decision)
- E8 optional: Angular Aria spike; @Service for new services
**Track Nx (fully parallel)**
- E1a `nx affected` for CI test/lint (PRs; keep full run-many on master pushes; keep single full prod build per #549)
- E1b self-hosted remote cache: Nx 23 built-in HTTP cache protocol (`NX_SELF_HOSTED_REMOTE_CACHE_SERVER`) + MinIO-backed cache server on the swarm, token in OpenBao. NOT nx-remotecache-* (dead API on 23), NOT @nx/s3-cache (activation key), never Nx Cloud
- E1c e2e becomes an Nx project via `@nx/playwright/plugin` inference (wraps in place, cache:false — API-connected suite)
- E2a local workspace generator for the 7-file lib pattern (test target + tags always emitted) + cached per-project `typecheck` targets (retires the unusable root tsc, fixes #664 pain)
- E2b test-target backfill on the 30 libs without one
**Conflict rules:** keep `@angular/build:unit-test` (never @nx/vite:test); @nx/playwright is the ONLY inference plugin; never mix formControlName and [formField] in one form; per-directory schematic runs only.
~35–38 PRs total. Plan of record: `~/.claude/plans/bright-dazzling-ritchie.md` (approved 2026-07-19).
E4 (resource() / httpResource()) is finished in code. The finishing wave is open as spikersoft-angular#458 (refactor(a22): E4 PR4 — finish resource()/httpResource adoption).
Prior E4 PRs already landed: PR1 pilot (#437), PR2 pure-reads (#440), PR3a blog (#441), PR3b art-studio sticky config (#442). PR4 covers the rest of the inventory plus the utilization gaps (reactive params, ticking-signal polling, parse, error opacity, defaultValue/hasValue documentation).
E4 will be done when #458 merges. The epic itself stays open — Angular track still has E5 (signal forms), E6 (@defer/injectAsync), E7 (router+HTTP), E8 (Aria spike); Nx track has E1b/E1c/E2a/E2b as planned.
## E4 status — completion PR open
E4 (`resource()` / `httpResource()`) is finished in code. The finishing wave is open as [spikersoft-angular#458](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/458) (`refactor(a22): E4 PR4 — finish resource()/httpResource adoption`).
Prior E4 PRs already landed: PR1 pilot (#437), PR2 pure-reads (#440), PR3a blog (#441), PR3b art-studio sticky config (#442). PR4 covers the rest of the inventory plus the utilization gaps (reactive params, ticking-signal polling, `parse`, error opacity, `defaultValue`/`hasValue` documentation).
**E4 will be done when #458 merges.** The epic itself stays open — Angular track still has E5 (signal forms), E6 (`@defer`/injectAsync), E7 (router+HTTP), E8 (Aria spike); Nx track has E1b/E1c/E2a/E2b as planned.
Next Reactive → Signal Forms surface is open as spikersoft-angular#460 (feat(a22): E5 wave1 — user-create on Signal Forms).
Still remaining in E5 wave 1: organization-registration → registration-stepper (COPPA) → child-account-dialog → employment/application-dialog → profile.
## E5 wave 1 — user-create PR open
[#459](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/459) (edit-health-info) is merged.
Next Reactive → Signal Forms surface is open as [spikersoft-angular#460](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/460) (`feat(a22): E5 wave1 — user-create on Signal Forms`).
**Still remaining in E5 wave 1:** organization-registration → registration-stepper (COPPA) → child-account-dialog → employment/application-dialog → profile.
Verified nothing else had claimed it (no open PRs / remote branches), then migrated the six-step create/edit child-account dialog: spikersoft-angular#474.
Notable firsts for the wave: applyWhen for conditional validation (passport fields only when international; credentials only in create mode) and the cross-field validate + valueOf password check. Username availability reuses the #462validateAsync resource shape.
Merge gate: manual QA of create + edit paths (auth-gated dialog — the anonymous e2e walk doesn't reach it).
Still remaining in E5 wave 1: employment/application-dialog → profile (+ SMS-consent subtree cleanup from #462). E6 PR2–4 and E5 wave 2 unchanged.
## E5 wave 1 — child-account-dialog PR open (COPPA)
Verified nothing else had claimed it (no open PRs / remote branches), then migrated the six-step create/edit child-account dialog: [spikersoft-angular#474](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/474).
Notable firsts for the wave: `applyWhen` for conditional validation (passport fields only when international; credentials only in create mode) and the cross-field `validate` + `valueOf` password check. Username availability reuses the #462 `validateAsync` resource shape.
**Merge gate:** manual QA of create + edit paths (auth-gated dialog — the anonymous e2e walk doesn't reach it).
**Still remaining in E5 wave 1:** employment/application-dialog → profile (+ SMS-consent subtree cleanup from #462). E6 PR2–4 and E5 wave 2 unchanged.
spikersoft-angular#476 — five FormGroups + four FormArrays migrated; the merge(valueChanges) progress tracker became a plain computed over the models, and Validators.requiredTrue maps to required() (Signal Forms treats false as empty). Small noted UX change: postal-code auto-fill now fires on blur instead of debounced keystrokes.
Gates run locally in order: full app build → full app test suite (2062 green) → lint.
E5 wave 1 remaining: profile (last, by design) + SMS-consent subtree cleanup from #462.
## E5 wave 1 — employment application-dialog PR open
[spikersoft-angular#476](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/476) — five FormGroups + four FormArrays migrated; the `merge(valueChanges)` progress tracker became a plain `computed` over the models, and `Validators.requiredTrue` maps to `required()` (Signal Forms treats `false` as empty). Small noted UX change: postal-code auto-fill now fires on blur instead of debounced keystrokes.
Gates run locally in order: full app build → full app test suite (2062 green) → lint.
(#474 child-account-dialog merged earlier today.)
**E5 wave 1 remaining: profile** (last, by design) + SMS-consent subtree cleanup from #462.
spikersoft-angular#478 — the #462 leftover. SmsConsentStateService's FormGroup-in-a-signal (with the _formValueChanged counter hack) became a model signal + form() FieldTree; host public API unchanged so the stepper was untouched apart from a stale comment. Spec now tests the real schema.
Merge gate:e2e-anonymous green on the PR + manual QA of the SMS step (registration = COPPA).
Gates run locally per step: full app build → full app suite (2059 green) → lint.
E5 wave 1 remaining: profile only. Recommend it as its own sitting — it's the largest Reactive surface in the app and was sequenced last deliberately.
## E5 — SMS-consent carve-out closed (COPPA)
[spikersoft-angular#478](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/478) — the #462 leftover. `SmsConsentStateService`'s FormGroup-in-a-signal (with the `_formValueChanged` counter hack) became a model signal + `form()` FieldTree; host public API unchanged so the stepper was untouched apart from a stale comment. Spec now tests the real schema.
**Merge gate:** `e2e-anonymous` green on the PR + manual QA of the SMS step (registration = COPPA).
Gates run locally per step: full app build → full app suite (2059 green) → lint.
**E5 wave 1 remaining: profile only.** Recommend it as its own sitting — it's the largest Reactive surface in the app and was sequenced last deliberately.
spikersoft-angular#479 migrates the last wave-1 surface: all six profile FormGroups (incl. the dynamic curated-palette group as a Record-shaped model), the three child components now take FieldTree inputs (first use of that pattern), the unsaved-changes guard rides aggregated root dirty(), and every valueChanges subscription became a template event.
When #479 merges, E5 wave 1 is done: 7/7 surfaces + pilot + SMS-consent carve-out — no Reactive Forms left in wave-1 scope.
Gates run locally per step: full app build → full app suite (2059 green) → lint. Suggested manual pass in the PR body (personal save, address verify/postal autofill, theme live preview, unsaved-changes dialog).
## E5 wave 1 COMPLETE — profile PR open
[spikersoft-angular#479](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/479) migrates the last wave-1 surface: all six profile FormGroups (incl. the dynamic curated-palette group as a Record-shaped model), the three child components now take `FieldTree` inputs (first use of that pattern), the unsaved-changes guard rides aggregated root `dirty()`, and every valueChanges subscription became a template event.
**When #479 merges, E5 wave 1 is done: 7/7 surfaces + pilot + SMS-consent carve-out — no Reactive Forms left in wave-1 scope.**
Gates run locally per step: full app build → full app suite (2059 green) → lint. Suggested manual pass in the PR body (personal save, address verify/postal autofill, theme live preview, unsaved-changes dialog).
**Angular track remaining:** E5 wave 2 (~63 ngModel screens, ngx-mask trio excluded) and E6 PR2–4 (Monaco/maps/emulators @defer). E8 optional.
E6 PR2 (Monaco/dev-tools cluster) open — measured, not assumed
spikersoft-angular#481. Chunk fingerprinting showed the cluster's presumed target (Monaco) is already lazy via MonacoLoaderService — the real offender was quicktype-core riding the /tools/json route. One-file fix (loader-cached dynamic import): navigation payload 3370 → 2293 KB raw (−32%), measured by static-closure walk over the dist chunk graph. Blockly stays as-is (deliberate static share with the voxel robot panel); duckdb needs nothing.
spikersoft-angular#482 — defer-wrapper for /tracker: navigation payload 1437 KB → 11 KB (maplibre-gl streams in behind an on-viewport @defer). Leaflet measured at ~150 KB per copy and left alone (country-map is profile-adjacent while #479 is in review). Gates: build → i18n:scan (one pre-existing finding, verified on clean master) → 2061 tests green → lint.
## E6 PR3 (maps cluster) open
[spikersoft-angular#482](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/482) — defer-wrapper for `/tracker`: navigation payload **1437 KB → 11 KB** (maplibre-gl streams in behind an on-viewport `@defer`). Leaflet measured at ~150 KB per copy and left alone (country-map is profile-adjacent while #479 is in review). Gates: build → i18n:scan (one pre-existing finding, verified on clean master) → 2061 tests green → lint.
Next: **E6 PR4, emulators cluster (v86 / alpine-linux / mac-plus)** — same measure-first approach.
spikersoft-angular#483. Measurement said: v86 already lazy by construction (~220 KB route closure, engine runtime-fetched); the real find was alpine-linux + mac-plus dead since the v86 catalogue, orphaning 158 MB of ROM/disk-image assets — 232 MB in every dist/image/deploy. Deleted (ticket #751).
E6 = 4/4 clusters done (three.js #449, dev-tools #481 −32% /tools/json, maps #482 1437→11 KB /tracker, emulators #483 −232 MB dist). Every cluster's action came from chunk-graph measurement, not the plan's assumptions.
Wave-1 profile (#479) and both E6 PRs (#482/#483) merged — thanks. #751 closed after #483.
spikersoft-angular#484 — wave-2 batch 1: six admin screens (activity-coverage, child-account-review, contact-messages, platform-adoption, hiring-management, fact-review) off ngModel. Pattern: form(signal) + [formField] (signals stay the models, FormsModule and setter-handlers deleted); one NG8022 (min attr) moved into schema.
Wave-2 ledger: 6/63 templates migrated. Next batches: admin heavy trio (skill-management 32 bindings, location-management 12, geography-facts 8), then keycloak-admin lib (5 templates), dev-tools libs (~16), art-studio (7), marks-site (4), rest.
## E5 wave 2 started — batch 1 open
Wave-1 profile (#479) and both E6 PRs (#482/#483) merged — thanks. #751 closed after #483.
[spikersoft-angular#484](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/484) — wave-2 batch 1: six admin screens (activity-coverage, child-account-review, contact-messages, platform-adoption, hiring-management, fact-review) off ngModel. Pattern: `form(signal)` + `[formField]` (signals stay the models, FormsModule and setter-handlers deleted); one NG8022 (min attr) moved into schema.
**Wave-2 ledger: 6/63 templates migrated.** Next batches: admin heavy trio (skill-management 32 bindings, location-management 12, geography-facts 8), then keycloak-admin lib (5 templates), dev-tools libs (~16), art-studio (7), marks-site (4), rest.
spikersoft-angular#486 — skill-management (16 mechanical signal bridges), location-management (11 loose props → three object models, side effects via native (change)), geography-facts-management (bonus find: a full Reactive fb.group + FormArray the wave-1 inventory missed — now a proper model + form() schema; FormBuilder deleted from the file). Five NG8022 attr→schema moves.
Wave-2 ledger: 9/63 templates; admin cluster done. Gates per screen: intermediate builds (caught every NG8022) → full suite (2059) → lint.
Next batch: keycloak-admin lib (5 templates).
## E5 wave 2 batch 2 open — admin cluster complete
[spikersoft-angular#486](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/486) — skill-management (16 mechanical signal bridges), location-management (11 loose props → three object models, side effects via native `(change)`), geography-facts-management (bonus find: a full Reactive `fb.group` + FormArray the wave-1 inventory missed — now a proper model + `form()` schema; FormBuilder deleted from the file). Five NG8022 attr→schema moves.
**Wave-2 ledger: 9/63 templates; admin cluster done.** Gates per screen: intermediate builds (caught every NG8022) → full suite (2059) → lint.
Next batch: keycloak-admin lib (5 templates).
spikersoft-angular#487 — all five keycloak-admin templates; ngModel count in the lib is zero. group-create-dialog was a hybrid (compat ReactiveFormField + FormBuilder + in-place array mutation via ngModel) — now a clean model + form() schema with the array-index FieldTree idiom.
⚠️ Inventory drift, second sighting: the lib holds five MORE Reactive Forms components no wave inventory listed (role-create-dialog, user-roles, user-federated-identity, user-list, user-password-reset) — like geography-facts in batch 2. Wave 2 will need an addendum batch for stray Reactive surfaces; I'll do a repo-wide FormBuilder|formControlName census when the ngModel ledger closes so the epic ends at true zero.
## E5 wave 2 batch 3 open — keycloak-admin done
[spikersoft-angular#487](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/487) — all five keycloak-admin templates; ngModel count in the lib is zero. group-create-dialog was a hybrid (compat `ReactiveFormField` + FormBuilder + in-place array mutation via ngModel) — now a clean model + `form()` schema with the array-index FieldTree idiom.
**⚠️ Inventory drift, second sighting:** the lib holds five MORE Reactive Forms components no wave inventory listed (role-create-dialog, user-roles, user-federated-identity, user-list, user-password-reset) — like geography-facts in batch 2. Wave 2 will need an **addendum batch for stray Reactive surfaces**; I'll do a repo-wide `FormBuilder|formControlName` census when the ngModel ledger closes so the epic ends at true zero.
**Wave-2 ledger: 14/63.** Next: dev-tools libs (~16 templates).
spikersoft-angular#488. Infrastructure piece: MonacoEditorComponent now has a signal-native value model() beside its CVA — editor panes shouldn't need FormsModule; the ngModel path stays for unmigrated callsites. Nine templates migrated (blockly, diagram, duckdb, base64, both x86 panels, ipv4, quick-type main, save-name dialog); four new NG8022 variants for the playbook ([disabled] bindings → schema disabled(), name attr, maxlength).
spikersoft-angular#489 — the heavy seven (~124 bindings). Highlight: the two csv/json converters replaced fifteen per-field handlers with one tracking effect(() => validateAndConvert()). Both quick-type settings modals became model+FieldTree with numeric bounds in schema.
⛓️Merge order: #488 first, then #489 (it's stacked on #488's Monaco [(value)] wrapper; base is the batch-4 branch). Reminder: #488's earlier CI red was the verified runner-OOM — rerun on the same commit went green.
## E5 wave 2 batch 5 open — dev-tools cluster COMPLETE (stacked)
[spikersoft-angular#489](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/489) — the heavy seven (~124 bindings). Highlight: the two csv/json converters replaced **fifteen** per-field handlers with one tracking `effect(() => validateAndConvert())`. Both quick-type settings modals became model+FieldTree with numeric bounds in schema.
⛓️ **Merge order: #488 first, then #489** (it's stacked on #488's Monaco `[(value)]` wrapper; base is the batch-4 branch). Reminder: #488's earlier CI red was the verified runner-OOM — rerun on the same commit went green.
**Wave-2 ledger: 30/63.** Remaining: art-studio (7) → marks-site (4) → misc app screens (~14: voxel block-editor ×2, chat, book-upload, epub annotation, git-playground, cpu-dashboard, map-tracker, ai-assistant, language-runner, clang videos pane, save-content-dialog, js-formatting-options, fundraiser ×3, sponsor ×3, blog dialog, games-clue-for-sql, trellis-3d) → stray-Reactive census addendum.
Six templates migrated; the seventh inventory entry (art-asset-viewer) was a grep false positive (isLoadingModel contains the ngModel substring), so the true wave-2 denominator is 62, and the ledger now stands at 36/62 with the admin, keycloak-admin, dev-tools, and art-studio clusters all at zero ngModel.
Notables from this batch:
prompt-composer / photo-touch-up / photo-stack: straight form(signal) conversions with the min/max/maxlength attrs moved into schemas (the recurring NG8022 class).
preset-manager (nullable open-editor model) and review-queue / gallery-moderation (per-asset keyed note map) keep native [value] + (input) — documented [formField] exceptions, same category as batch 5's CSV-list fields.
New gotcha for the remaining batches: [maxlength]property bindings only compile via FormsModule's validator directive; once FormsModule leaves the imports they go NG8002 → convert to [attr.maxlength].
All local gates green (full build, full app suite 2065 tests, lib test+lint, fixers). Next: marks-site (4 templates), then the misc app screens, then the repo-wide stray-Reactive census to close the wave at true zero.
**E5 wave-2 batch 6 up: art-studio — PR [spikersoft-angular#490](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/490).**
Six templates migrated; the seventh inventory entry (art-asset-viewer) was a grep false positive (`isLoadingModel` contains the `ngModel` substring), so the true wave-2 denominator is **62**, and the ledger now stands at **36/62** with the admin, keycloak-admin, dev-tools, and art-studio clusters all at zero ngModel.
Notables from this batch:
- prompt-composer / photo-touch-up / photo-stack: straight `form(signal)` conversions with the min/max/maxlength attrs moved into schemas (the recurring NG8022 class).
- preset-manager (nullable open-editor model) and review-queue / gallery-moderation (per-asset keyed note map) keep native `[value]` + `(input)` — documented `[formField]` exceptions, same category as batch 5's CSV-list fields.
- New gotcha for the remaining batches: `[maxlength]` *property* bindings only compile via FormsModule's validator directive; once FormsModule leaves the imports they go NG8002 → convert to `[attr.maxlength]`.
All local gates green (full build, full app suite 2065 tests, lib test+lint, fixers). Next: marks-site (4 templates), then the misc app screens, then the repo-wide stray-Reactive census to close the wave at true zero.
marks-site is now FormsModule-free. The three entity-array editors (address / generator / propane-tank) are documented native-binding conversions rather than formField: they mutate DTO entries in place on the parent-owned customer object, and the parent's Save sends that whole object relying on those mutations — Signal Forms' immutable, component-local writes would fork that data flow and drop sibling-tab edits on save. A numberFromInput helper preserves ngModel's empty→null semantics on numeric inputs, and nullable numerics bind via value="{{ x }}" interpolation (renders null as empty). marks-customer keeps its Reactive form per the locked ngx-mask decision, but its standalone customerType radio ngModel is gone too.
All local gates green (build first-pass, 2065-test app suite, lint, fixers). Remaining: the misc app-screens batch(es) (~22 templates), then the repo-wide stray-Reactive census.
**E5 wave-2 batch 7 up: marks-site — PR [spikersoft-angular#492](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/492). Ledger: 40/62.**
marks-site is now FormsModule-free. The three entity-array editors (address / generator / propane-tank) are documented native-binding conversions rather than formField: they mutate DTO entries in place on the parent-owned customer object, and the parent's Save sends that whole object relying on those mutations — Signal Forms' immutable, component-local writes would fork that data flow and drop sibling-tab edits on save. A `numberFromInput` helper preserves ngModel's empty→null semantics on numeric inputs, and nullable numerics bind via `value="{{ x }}"` interpolation (renders null as empty). marks-customer keeps its Reactive form per the locked ngx-mask decision, but its standalone customerType radio ngModel is gone too.
All local gates green (build first-pass, 2065-test app suite, lint, fixers). Remaining: the misc app-screens batch(es) (~22 templates), then the repo-wide stray-Reactive census.
blog moderation-dialog, the fundraiser trio, clue-for-sql, the sponsor trio, and trellis-3d-generator are all FormsModule-free. Mostly straight form(signal) conversions; handler-driven controls (sanitizing inputs, slider thumbs, native selects with number ids) documented as native-binding exceptions. Two bonus finds: a latent double-write race on trellis's wireframe toggle (ngModel + (change) both flipping the same signal) now has a single writer, and three blog components carried dead FormsModule imports — stripped.
All local gates green (intermediate builds caught two conversion slips, five-lib test+lint 183 tests, full build, 2065-test app suite, lint, fixers).
**E5 wave-2 batch 8 up: five feature libs — PR [spikersoft-angular#493](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/493). Ledger: 49/62.**
blog moderation-dialog, the fundraiser trio, clue-for-sql, the sponsor trio, and trellis-3d-generator are all FormsModule-free. Mostly straight `form(signal)` conversions; handler-driven controls (sanitizing inputs, slider thumbs, native selects with number ids) documented as native-binding exceptions. Two bonus finds: a latent double-write race on trellis's wireframe toggle (ngModel + (change) both flipping the same signal) now has a single writer, and three blog components carried dead FormsModule imports — stripped.
All local gates green (intermediate builds caught two conversion slips, five-lib test+lint 183 tests, full build, 2065-test app suite, lint, fixers).
Remaining: platform/shared/game libs (~6 templates: wasm-voxel ×2, clang videos pane, language-runner, js-formatting-options, save-content-dialog) → app screens (~7) → repo-wide stray-Reactive census.
E5 wave-2 batch 9 up: game/platform/shared libs — PR spikersoft-angular#494. Ledger: 55/62 — libraries/ is now completely ngModel/FormsModule-free.
wasm-voxel's two block editors (in-place entity mutation with engine-refresh hooks → native bindings, matching their sliders which were already native), language-runner (both monacos onto the wrapper's [(value)] — the CVA path there is now retired), clang videos pane, js-formatting panel (store-setter native bindings; @for select options need [selected] — select-level [value] races option creation on first render), save-content-dialog.
All gates green: build first pass, five-lib test+lint (245 tests), full app suite (2065), lint, fixers.
Final stretch: the seven app screens (voxel wrapper hits are done — remaining are chat, book-upload, cpu-dashboard, epub annotation-dialog, git-playground, map-tracker, ai-assistant message-input), then the stray-Reactive census closes the wave.
**E5 wave-2 batch 9 up: game/platform/shared libs — PR [spikersoft-angular#494](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/494). Ledger: 55/62 — `libraries/` is now completely ngModel/FormsModule-free.**
wasm-voxel's two block editors (in-place entity mutation with engine-refresh hooks → native bindings, matching their sliders which were already native), language-runner (both monacos onto the wrapper's `[(value)]` — the CVA path there is now retired), clang videos pane, js-formatting panel (store-setter native bindings; `@for` select options need `[selected]` — select-level `[value]` races option creation on first render), save-content-dialog.
All gates green: build first pass, five-lib test+lint (245 tests), full app suite (2065), lint, fixers.
Final stretch: the seven app screens (voxel wrapper hits are done — remaining are chat, book-upload, cpu-dashboard, epub annotation-dialog, git-playground, map-tracker, ai-assistant message-input), then the stray-Reactive census closes the wave.
Ledger correction from the census: the inventory was built from .html files, so six components with inline templates never made it in (program-library, robot-programming-panel, application-review, staff-issues' two embedded dialogs, delete-account-dialog, sql-theme-picker). True denominator was 68, and all 68 are now converted across batches 1–11. #496 also strips 17 verified-dead FormsModule imports (day-scheduler deliberately kept — bare <form> needs a submit guard before its NgForm can go).
Open PRs awaiting merge: #492 (marks-site), #493 (feature libs), #494 (platform/shared/game libs), #495 (app screens), #496 (census). All five are independent branches off master, each fully gated locally (build, 2065-test app suite, per-lib tests, lint, fixers). Once merged, the repo is at zero ngModel.
Remaining E5 work after this wave (separate follow-up series, not ngModel debt): the stray-Reactive migrations — 10 keycloak-admin components, dev-tools jwt, interests-management, calendar event-modal, landing-page, geography-explorer — plus the locked ngx-mask trio which stays Reactive until ngx-mask supports Signal Forms, and day-scheduler's guarded FormsModule.
**E5 wave-2 ngModel migration: COMPLETE (pending merges). Batches 10+11 up: PRs [#495](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/495) (final seven app screens) and [#496](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/496) (census).**
Ledger correction from the census: the inventory was built from `.html` files, so six components with **inline templates** never made it in (program-library, robot-programming-panel, application-review, staff-issues' two embedded dialogs, delete-account-dialog, sql-theme-picker). True denominator was **68, and all 68 are now converted** across batches 1–11. #496 also strips 17 verified-dead FormsModule imports (day-scheduler deliberately kept — bare `<form>` needs a submit guard before its NgForm can go).
**Open PRs awaiting merge: #492 (marks-site), #493 (feature libs), #494 (platform/shared/game libs), #495 (app screens), #496 (census).** All five are independent branches off master, each fully gated locally (build, 2065-test app suite, per-lib tests, lint, fixers). Once merged, the repo is at zero ngModel.
**Remaining E5 work after this wave (separate follow-up series, not ngModel debt):** the stray-Reactive migrations — 10 keycloak-admin components, dev-tools jwt, interests-management, calendar event-modal, landing-page, geography-explorer — plus the locked ngx-mask trio which stays Reactive until ngx-mask supports Signal Forms, and day-scheduler's guarded FormsModule.
E5 Reactive follow-up series started. R1 up: keycloak-admin small six — PR spikersoft-angular#497.
role-create-dialog, user-password-reset, user-federated-identity, user-list (debounced search → toObservable(model); its distinctUntilChanged() was a reference-compare no-op and is dropped), user-roles (client select now writes the resource-driving signal directly, selectedClientId middleman deleted; the two search fields turned out to be bound-but-never-consumed — preserved as inert), and user-information (dynamic attributes Record via [formField]="userForm.attributes[key]", root disabled() schema replacing the parent-triggered enable/disable, so updateFormState() is gone). Plus the landing-page dead stepper FormGroups from the census.
New playbook entry: [readonly] is another NG8022-forbidden binding on formField nodes (and redundant under a disabled() schema).
All gates green: intermediate builds, keycloak-admin test+lint (147), full app suite (2065), lint, fixers. Specs rewritten to the field API where they drove the old controls.
Next: R2 — keycloak-admin heavy trio (client-create-dialog, client-edit-dialog, realm-settings — 34 controls), then R3 — app screens (interests-management, calendar event-modal, geography-explorer, dev-tools jwt). After R3, FormBuilder/formControlName remains only in the locked ngx-mask screens.
**E5 Reactive follow-up series started. R1 up: keycloak-admin small six — PR [spikersoft-angular#497](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/497).**
role-create-dialog, user-password-reset, user-federated-identity, user-list (debounced search → `toObservable(model)`; its `distinctUntilChanged()` was a reference-compare no-op and is dropped), user-roles (client select now writes the resource-driving signal directly, `selectedClientId` middleman deleted; the two search fields turned out to be bound-but-never-consumed — preserved as inert), and user-information (dynamic attributes Record via `[formField]="userForm.attributes[key]"`, root `disabled()` schema replacing the parent-triggered enable/disable, so `updateFormState()` is gone). Plus the landing-page dead stepper FormGroups from the census.
New playbook entry: **`[readonly]` is another NG8022-forbidden binding on formField nodes** (and redundant under a `disabled()` schema).
All gates green: intermediate builds, keycloak-admin test+lint (147), full app suite (2065), lint, fixers. Specs rewritten to the field API where they drove the old controls.
Next: **R2 — keycloak-admin heavy trio** (client-create-dialog, client-edit-dialog, realm-settings — 34 controls), then **R3 — app screens** (interests-management, calendar event-modal, geography-explorer, dev-tools jwt). After R3, `FormBuilder`/`formControlName` remains only in the locked ngx-mask screens.
R2 up: keycloak-admin heavy trio — PR spikersoft-angular#498. With R1+R2 merged, keycloak-admin is 100% Signal Forms.
Client dialogs (17-field shared model, required+pattern schema), realm-settings (34-field model, disabled(path.realm) replacing the disabled-control config). Notable latent-bug fix: Reactive .value excludes disabled controls, so realm-settings has been sending realm: undefined on every save (masked by Keycloak keying updates off the URL) — the model form now sends the real value.
Next: R3 — the last four Reactive app screens (interests-management, calendar event-modal, geography-explorer, dev-tools jwt).
**R2 up: keycloak-admin heavy trio — PR [spikersoft-angular#498](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/498). With R1+R2 merged, keycloak-admin is 100% Signal Forms.**
Client dialogs (17-field shared model, required+pattern schema), realm-settings (34-field model, `disabled(path.realm)` replacing the disabled-control config). Notable latent-bug fix: Reactive `.value` excludes disabled controls, so realm-settings has been sending `realm: undefined` on every save (masked by Keycloak keying updates off the URL) — the model form now sends the real value.
Gates green: build, keycloak-admin 147 tests + lint, full 2065-test suite, lint, fixers.
Next: R3 — the last four Reactive app screens (interests-management, calendar event-modal, geography-explorer, dev-tools jwt).
R3 up — the Reactive→Signal Forms series is COMPLETE: PR spikersoft-angular#499 (interests-management, calendar event-modal, geography-explorer, dev-tools jwt).
FormsModule: only day-scheduler's guarded import (bare <form> needs a submit guard first)
FormBuilder/formControlName: only marks-customer (locked until ngx-mask supports Signal Forms) and a comment in the contact pilot
Highlights from R3: event-modal is the workspace's first datepicker/timepicker on [formField] (Signal Forms drives their CVAs) and drops its valueChanges→markForCheck OnPush hack; jwt's parallel jwtToken/secret signals became computed() views of the model after a naive mirror-effect port looped (decode pretty-prints into the model → mirror clobbers the token — the spec caught it, which is exactly what the gates are for). One process slip, self-reported on the PR: the R3 commit briefly went up with a red event-modal spec because my gate chain short-circuited; fixed in the follow-up commit and the branch head is fully green (2065 tests).
**R3 up — the Reactive→Signal Forms series is COMPLETE: PR [spikersoft-angular#499](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/499)** (interests-management, calendar event-modal, geography-explorer, dev-tools jwt).
**E5 end state once #498 and #499 merge:**
- ngModel: zero (wave 2, 68/68, all merged)
- FormsModule: only day-scheduler's guarded import (bare `<form>` needs a submit guard first)
- FormBuilder/formControlName: only marks-customer (locked until ngx-mask supports Signal Forms) and a comment in the contact pilot
Highlights from R3: event-modal is the workspace's first datepicker/timepicker on `[formField]` (Signal Forms drives their CVAs) and drops its `valueChanges→markForCheck` OnPush hack; jwt's parallel `jwtToken`/`secret` signals became `computed()` views of the model after a naive mirror-effect port looped (decode pretty-prints into the model → mirror clobbers the token — the spec caught it, which is exactly what the gates are for). One process slip, self-reported on the PR: the R3 commit briefly went up with a red event-modal spec because my gate chain short-circuited; fixed in the follow-up commit and the branch head is fully green (2065 tests).
E5 R4 up — the census's third blind spot, and the true finish line: PR spikersoft-angular#500.
The FormBuilder/formControlName regex never covered standalone new FormControl() + [formControl]. Post-merge sweep found four users (reader book-search-panel's debounced search, marks-customer-list's filters, dev-tools json-storage-selector-modal's autocomplete, marks-service-contract's tab-index value boxes) — all converted — plus three more dead FormsModule imports including day-scheduler, whose pointless bare <form> is unwrapped, retiring the last documented holdout.
E5 exit state once #500 merges: zero ngModel · zero FormsModule · zero Reactive forms except marks-customer (ngx-mask lock) and explanatory comments. Census lesson recorded for posterity: sweep all THREE idioms — ngModel (word-boundary, .ts AND .html for inline templates), FormBuilder|formControlName, and new FormControl|[formControl].
All gates green (build, touched-lib tests, 2065-test app suite ×2 with exit codes checked, lint, fixers).
**E5 R4 up — the census's third blind spot, and the true finish line: PR [spikersoft-angular#500](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/500).**
The FormBuilder/formControlName regex never covered standalone `new FormControl()` + `[formControl]`. Post-merge sweep found four users (reader book-search-panel's debounced search, marks-customer-list's filters, dev-tools json-storage-selector-modal's autocomplete, marks-service-contract's tab-index value boxes) — all converted — plus three more dead FormsModule imports including day-scheduler, whose pointless bare `<form>` is unwrapped, retiring the last documented holdout.
**E5 exit state once #500 merges:** zero ngModel · zero FormsModule · zero Reactive forms except marks-customer (ngx-mask lock) and explanatory comments. Census lesson recorded for posterity: sweep all THREE idioms — `ngModel` (word-boundary, .ts AND .html for inline templates), `FormBuilder|formControlName`, and `new FormControl|[formControl]`.
All gates green (build, touched-lib tests, 2065-test app suite ×2 with exit codes checked, lint, fixers).
Findings, @Service: stable @publicApi in core 22.0.6, with exactly the shapes we need — @Service() auto-provided (replaces @Injectable({providedIn:'root'})), {autoProvided:false} for providers-list services, {factory} for useFactory. Core also ships provideIdleServiceWith for tuning injectAsync idle-prefetch if E6's loaders ever need it. The workspace generator doesn't scaffold services, so the "generator default" from the plan materializes as a mirrored rule doc: new services use @Service(), no sweep of the ~213 existing (locked).
Findings, Angular Aria:@angular/aria 22.0.4 is already a dependency and already piloted — the x86 playground's mobile debugger tabs run on @angular/aria/tabs. The package ships eight headless, signal-based primitives (accordion, combobox, grid, listbox, menu, tabs, toolbar, tree), each with a testing harness. Spike inventory of hand-rolled role=/keydown widgets worth opportunistic migration (recorded in the rule): clang playground shell (9 role attrs), reg-ex (8), info-vault (6), language-runner, sql-runner, js-debug-vars, art-pipe shell — and the cleanest single example, event-modal's color picker (role="radio" + three keydown handlers per swatch → listbox/radio primitive). Recommendation: adopt for new widgets + opportunistic rework, no sweep; Material components stay Material. WebMCP ban restated.
Nx track: E1a affected-CI ✅ · E1b MinIO remote cache ✅ (trust window on --skip-nx-cache runs to ~2026-08-03) · E1c playwright plugin ✅ · E2a generator+typecheck ✅ · E2b test backfill ✅ (0 libs without a test target today)
Awaiting merge: #500 (forms-census finisher) and #501 (this spike). After those, the only deliberately-open threads are: marks-customer stays Reactive until ngx-mask ships Signal Forms support, and dropping --skip-nx-cache when the cache trust window closes. Suggest keeping the epic open until both PRs land, then closing it.
## E8 spike delivered — the epic's final effort: PR [spikersoft-angular#501](https://git.spikersoft.com/spikerj/spikersoft-angular/pulls/501)
**Findings, `@Service`:** stable `@publicApi` in core 22.0.6, with exactly the shapes we need — `@Service()` auto-provided (replaces `@Injectable({providedIn:'root'})`), `{autoProvided:false}` for providers-list services, `{factory}` for useFactory. Core also ships `provideIdleServiceWith` for tuning `injectAsync` idle-prefetch if E6's loaders ever need it. The workspace generator doesn't scaffold services, so the "generator default" from the plan materializes as a mirrored rule doc: **new services use `@Service()`, no sweep of the ~213 existing** (locked).
**Findings, Angular Aria:** `@angular/aria` 22.0.4 is *already* a dependency and *already* piloted — the x86 playground's mobile debugger tabs run on `@angular/aria/tabs`. The package ships eight headless, signal-based primitives (accordion, combobox, grid, listbox, menu, tabs, toolbar, tree), each with a testing harness. Spike inventory of hand-rolled `role=`/keydown widgets worth opportunistic migration (recorded in the rule): clang playground shell (9 role attrs), reg-ex (8), info-vault (6), language-runner, sql-runner, js-debug-vars, art-pipe shell — and the cleanest single example, event-modal's color picker (`role="radio"` + three keydown handlers per swatch → listbox/radio primitive). Recommendation: adopt for new widgets + opportunistic rework, no sweep; Material components stay Material. WebMCP ban restated.
## Epic status: ALL EFFORTS DELIVERED
- **Angular track:** E0 ✅ · E3 ✅ · E4 ✅ · E5 ✅ (pilot + wave 1 9/9 + wave 2 68/68 + Reactive follow-up R1–R4) · E6 ✅ (4/4 clusters, measured) · E7 ✅ · E8 ✅ (#501)
- **Nx track:** E1a affected-CI ✅ · E1b MinIO remote cache ✅ (trust window on `--skip-nx-cache` runs to ~2026-08-03) · E1c playwright plugin ✅ · E2a generator+typecheck ✅ · E2b test backfill ✅ (0 libs without a test target today)
**Awaiting merge: #500 (forms-census finisher) and #501 (this spike).** After those, the only deliberately-open threads are: marks-customer stays Reactive until ngx-mask ships Signal Forms support, and dropping `--skip-nx-cache` when the cache trust window closes. Suggest keeping the epic open until both PRs land, then closing it.
Epic status roll-up — audited the E-series against origin/master. This epic is close to done and its remaining items are small, which the 27-comment thread makes hard to see at a glance.
E0/E3:main.yml:387 sets NX_SELF_HOSTED_REMOTE_CACHE_SERVER=https://nx-cache.spikersoft.com with the token pulled from OpenBao at :399 (secret/ci/angular/nx-cache). The locked MinIO-backed decision is live.
E1a/E2a: confirmed while auditing #824 (now closed). PRs run nx affected -t lint/-t test against origin/master with full run-many on pushes; the per-project typecheck target inferred by tools/workspace-plugin replaced the unusable root tsc --noEmit. Worth recording explicitly: that means #664 was routed around rather than resolved — the root typecheck is still unusable, but nothing depends on it now.
E5 is one file from done.git grep -l "ngModel" across all app and library templates returns exactly one: libraries/features/art-studio/src/lib/art-asset-viewer/art-asset-viewer.component.html. Notably that is not one of the three documented ngx-mask blockers — only marks-customer.component.html still carries ngx-mask, so the blocker set has shrunk too. Worth checking whether the art-asset-viewer one is a genuine blocker or simply missed by the wave.
E7: no server.ts, @angular/ssr or prerender references remain in projects/spikersoft/project.json.
E8 is behaving as the locked decision intended — 3 files use @Service() and 5 import @angular/aria. Both were scoped as "new code adopts, no sweep", so low counts are the expected outcome, not a gap.
What's genuinely left, and it's outside the E-series:
#742 — the workspace-plugin library generator emits ^undefined peers (reads dependencies, but framework deps moved to devDependencies at E0) plus a second bug I found: tslib already carries its caret, so the template emits ^^2.8.1. All three peer entries are malformed. Only one commit has ever touched that file.
#728 — the Storybook static build is broken on master (all four items), and nothing runs build-storybook in CI, so it has no rot detection.
#830 — the flaky-spec fix landed on the named spec only; 9 sibling keycloak-admin specs still use the fixed-hop settle().
None of those blocks the modernization itself. Suggested: finish the one ngModel file, then either close this epic and let #742/#728/#830 stand alone, or explicitly re-scope it to include them — right now they're orbiting the epic without being tracked in it.
**Epic status roll-up** — audited the E-series against `origin/master`. **This epic is close to done and its remaining items are small**, which the 27-comment thread makes hard to see at a glance.
| Effort | State |
|---|---|
| E0/E3 — Nx self-hosted remote cache | ✅ live in CI |
| E1a — affected lint/test on PRs | ✅ shipped |
| E2a — per-project typecheck | ✅ shipped, and it routed around #664 |
| E5 — Signal Forms migration | ✅ **effectively complete — 1 file left** |
| E7 — strip SSR | ✅ done |
| E8 — `@Service` / `@angular/aria` conventions | ✅ rules landed, adoption opportunistic by design |
**E0/E3:** `main.yml:387` sets `NX_SELF_HOSTED_REMOTE_CACHE_SERVER=https://nx-cache.spikersoft.com` with the token pulled from OpenBao at `:399` (`secret/ci/angular/nx-cache`). The locked MinIO-backed decision is live.
**E1a/E2a:** confirmed while auditing #824 (now closed). PRs run `nx affected -t lint`/`-t test` against `origin/master` with full `run-many` on pushes; the per-project `typecheck` target inferred by `tools/workspace-plugin` replaced the unusable root `tsc --noEmit`. Worth recording explicitly: **that means #664 was routed around rather than resolved** — the root typecheck is still unusable, but nothing depends on it now.
**E5 is one file from done.** `git grep -l "ngModel"` across all app and library templates returns exactly **one**: `libraries/features/art-studio/src/lib/art-asset-viewer/art-asset-viewer.component.html`. Notably that is *not* one of the three documented ngx-mask blockers — only `marks-customer.component.html` still carries `ngx-mask`, so the blocker set has shrunk too. Worth checking whether the art-asset-viewer one is a genuine blocker or simply missed by the wave.
**E7:** no `server.ts`, `@angular/ssr` or `prerender` references remain in `projects/spikersoft/project.json`.
**E8** is behaving as the locked decision intended — 3 files use `@Service()` and 5 import `@angular/aria`. Both were scoped as "new code adopts, no sweep", so low counts are the expected outcome, not a gap.
**What's genuinely left, and it's outside the E-series:**
- **#742** — the workspace-plugin library generator emits `^undefined` peers (reads `dependencies`, but framework deps moved to `devDependencies` at E0) **plus** a second bug I found: `tslib` already carries its caret, so the template emits `^^2.8.1`. All three peer entries are malformed. Only one commit has ever touched that file.
- **#728** — the Storybook static build is broken on master (all four items), and **nothing runs `build-storybook` in CI**, so it has no rot detection.
- **#830** — the flaky-spec fix landed on the named spec only; **9 sibling keycloak-admin specs** still use the fixed-hop `settle()`.
None of those blocks the modernization itself. **Suggested:** finish the one ngModel file, then either close this epic and let #742/#728/#830 stand alone, or explicitly re-scope it to include them — right now they're orbiting the epic without being tracked in it.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Adopt everything Angular 22 and Nx 23 offer across spikersoft-angular. The runtime upgrade is done (22.0.6 / 23.1.0); this epic is adoption. Inventory confirmed we're already zoneless + 100% OnPush + control-flow-migrated + inject()-everywhere — the real gaps and the effort series:
Track Angular
paramsInheritanceStrategy: 'always', ngClass/ngStyle schematics, drop unused zone.js dep, re-include 5 lint-excluded projects@ViewChild×73 /@Input×14 /@Output×14 / constructor-DI×6 via official v22 schematics[formField]+ Material 22 error tracking + shared Transloco error helper; wave 1 = 9 Reactive surfaces small→large (profile last); wave 2 = ALL 63 ngModel screens (decision: migrate everything). MatStepper: drop [stepControl] for completed/editable computeds. ngx-mask files (3) stay Reactive until supportedTrack Nx (fully parallel)
nx affectedfor CI test/lint (PRs; keep full run-many on master pushes; keep single full prod build per #549)NX_SELF_HOSTED_REMOTE_CACHE_SERVER) + MinIO-backed cache server on the swarm, token in OpenBao. NOT nx-remotecache-* (dead API on 23), NOT @nx/s3-cache (activation key), never Nx Cloud@nx/playwright/plugininference (wraps in place, cache:false — API-connected suite)typechecktargets (retires the unusable root tsc, fixes #664 pain)Conflict rules: keep
@angular/build:unit-test(never @nx/vite:test); @nx/playwright is the ONLY inference plugin; never mix formControlName and [formField] in one form; per-directory schematic runs only.~35–38 PRs total. Plan of record:
~/.claude/plans/bright-dazzling-ritchie.md(approved 2026-07-19).E4 status — completion PR open
E4 (
resource()/httpResource()) is finished in code. The finishing wave is open as spikersoft-angular#458 (refactor(a22): E4 PR4 — finish resource()/httpResource adoption).Prior E4 PRs already landed: PR1 pilot (#437), PR2 pure-reads (#440), PR3a blog (#441), PR3b art-studio sticky config (#442). PR4 covers the rest of the inventory plus the utilization gaps (reactive params, ticking-signal polling,
parse, error opacity,defaultValue/hasValuedocumentation).E4 will be done when #458 merges. The epic itself stays open — Angular track still has E5 (signal forms), E6 (
@defer/injectAsync), E7 (router+HTTP), E8 (Aria spike); Nx track has E1b/E1c/E2a/E2b as planned.Progress update (2026-07-20)
E4 complete — spikersoft-angular#458 merged.
Epic status inventory (what's already on master):
@defer), E7edit-health-infodialog → Signal Forms (applyEachfor dynamic meds/contacts)Next up after #459: keycloak-admin
user-create.E5 wave 1 — user-create PR open
#459 (edit-health-info) is merged.
Next Reactive → Signal Forms surface is open as spikersoft-angular#460 (
feat(a22): E5 wave1 — user-create on Signal Forms).Still remaining in E5 wave 1: organization-registration → registration-stepper (COPPA) → child-account-dialog → employment/application-dialog → profile.
E5 wave 1 — organization-registration PR open
spikersoft-angular#461 — organization-registration on Signal Forms.
Also still open: #460 (user-create).
Still remaining in E5 wave 1: registration-stepper (COPPA) → child-account-dialog → employment/application-dialog → profile.
E5 wave 1 — registration-stepper PR open (COPPA)
spikersoft-angular#462 — registration-stepper Signal Forms (
validateAsyncavailability + MatStepper[completed]).Also open: #460 user-create, #461 org-registration.
Merge gate for #462: e2e registration walk + manual QA. SMS consent subtree left Reactive for a follow-up.
Still remaining in E5 wave 1 after this: child-account-dialog → employment/application-dialog → profile (+ SMS consent cleanup).
E5 wave 1 — child-account-dialog PR open (COPPA)
Verified nothing else had claimed it (no open PRs / remote branches), then migrated the six-step create/edit child-account dialog: spikersoft-angular#474.
Notable firsts for the wave:
applyWhenfor conditional validation (passport fields only when international; credentials only in create mode) and the cross-fieldvalidate+valueOfpassword check. Username availability reuses the #462validateAsyncresource shape.Merge gate: manual QA of create + edit paths (auth-gated dialog — the anonymous e2e walk doesn't reach it).
Still remaining in E5 wave 1: employment/application-dialog → profile (+ SMS-consent subtree cleanup from #462). E6 PR2–4 and E5 wave 2 unchanged.
E5 wave 1 — employment application-dialog PR open
spikersoft-angular#476 — five FormGroups + four FormArrays migrated; the
merge(valueChanges)progress tracker became a plaincomputedover the models, andValidators.requiredTruemaps torequired()(Signal Forms treatsfalseas empty). Small noted UX change: postal-code auto-fill now fires on blur instead of debounced keystrokes.Gates run locally in order: full app build → full app test suite (2062 green) → lint.
(#474 child-account-dialog merged earlier today.)
E5 wave 1 remaining: profile (last, by design) + SMS-consent subtree cleanup from #462.
E5 — SMS-consent carve-out closed (COPPA)
spikersoft-angular#478 — the #462 leftover.
SmsConsentStateService's FormGroup-in-a-signal (with the_formValueChangedcounter hack) became a model signal +form()FieldTree; host public API unchanged so the stepper was untouched apart from a stale comment. Spec now tests the real schema.Merge gate:
e2e-anonymousgreen on the PR + manual QA of the SMS step (registration = COPPA).Gates run locally per step: full app build → full app suite (2059 green) → lint.
E5 wave 1 remaining: profile only. Recommend it as its own sitting — it's the largest Reactive surface in the app and was sequenced last deliberately.
E5 wave 1 COMPLETE — profile PR open
spikersoft-angular#479 migrates the last wave-1 surface: all six profile FormGroups (incl. the dynamic curated-palette group as a Record-shaped model), the three child components now take
FieldTreeinputs (first use of that pattern), the unsaved-changes guard rides aggregated rootdirty(), and every valueChanges subscription became a template event.When #479 merges, E5 wave 1 is done: 7/7 surfaces + pilot + SMS-consent carve-out — no Reactive Forms left in wave-1 scope.
Gates run locally per step: full app build → full app suite (2059 green) → lint. Suggested manual pass in the PR body (personal save, address verify/postal autofill, theme live preview, unsaved-changes dialog).
Angular track remaining: E5 wave 2 (~63 ngModel screens, ngx-mask trio excluded) and E6 PR2–4 (Monaco/maps/emulators @defer). E8 optional.
E6 PR2 (Monaco/dev-tools cluster) open — measured, not assumed
spikersoft-angular#481. Chunk fingerprinting showed the cluster's presumed target (Monaco) is already lazy via
MonacoLoaderService— the real offender wasquicktype-coreriding the/tools/jsonroute. One-file fix (loader-cached dynamic import): navigation payload 3370 → 2293 KB raw (−32%), measured by static-closure walk over the dist chunk graph. Blockly stays as-is (deliberate static share with the voxel robot panel); duckdb needs nothing.Also open: #479 (profile — E5 wave 1 finale, awaiting manual QA).
E6 remaining: PR3 maps cluster (map-tracker chunk is 1.24 MB — promising), PR4 emulators. Then E5 wave 2.
E6 PR3 (maps cluster) open
spikersoft-angular#482 — defer-wrapper for
/tracker: navigation payload 1437 KB → 11 KB (maplibre-gl streams in behind an on-viewport@defer). Leaflet measured at ~150 KB per copy and left alone (country-map is profile-adjacent while #479 is in review). Gates: build → i18n:scan (one pre-existing finding, verified on clean master) → 2061 tests green → lint.Next: E6 PR4, emulators cluster (v86 / alpine-linux / mac-plus) — same measure-first approach.
E6 PR4 (emulators) open — E6 track complete
spikersoft-angular#483. Measurement said: v86 already lazy by construction (~220 KB route closure, engine runtime-fetched); the real find was alpine-linux + mac-plus dead since the v86 catalogue, orphaning 158 MB of ROM/disk-image assets — 232 MB in every dist/image/deploy. Deleted (ticket #751).
E6 = 4/4 clusters done (three.js #449, dev-tools #481 −32% /tools/json, maps #482 1437→11 KB /tracker, emulators #483 −232 MB dist). Every cluster's action came from chunk-graph measurement, not the plan's assumptions.
Open PRs awaiting merge: #479 (profile, manual QA), #482 (tracker defer), #483 (emulators cleanup).
Angular track remaining: E5 wave 2 only (~63 ngModel screens, ngx-mask trio excluded) + optional E8.
E5 wave 2 started — batch 1 open
Wave-1 profile (#479) and both E6 PRs (#482/#483) merged — thanks. #751 closed after #483.
spikersoft-angular#484 — wave-2 batch 1: six admin screens (activity-coverage, child-account-review, contact-messages, platform-adoption, hiring-management, fact-review) off ngModel. Pattern:
form(signal)+[formField](signals stay the models, FormsModule and setter-handlers deleted); one NG8022 (min attr) moved into schema.Wave-2 ledger: 6/63 templates migrated. Next batches: admin heavy trio (skill-management 32 bindings, location-management 12, geography-facts 8), then keycloak-admin lib (5 templates), dev-tools libs (~16), art-studio (7), marks-site (4), rest.
E5 wave 2 batch 2 open — admin cluster complete
spikersoft-angular#486 — skill-management (16 mechanical signal bridges), location-management (11 loose props → three object models, side effects via native
(change)), geography-facts-management (bonus find: a full Reactivefb.group+ FormArray the wave-1 inventory missed — now a proper model +form()schema; FormBuilder deleted from the file). Five NG8022 attr→schema moves.Wave-2 ledger: 9/63 templates; admin cluster done. Gates per screen: intermediate builds (caught every NG8022) → full suite (2059) → lint.
Next batch: keycloak-admin lib (5 templates).
E5 wave 2 batch 3 open — keycloak-admin done
spikersoft-angular#487 — all five keycloak-admin templates; ngModel count in the lib is zero. group-create-dialog was a hybrid (compat
ReactiveFormField+ FormBuilder + in-place array mutation via ngModel) — now a clean model +form()schema with the array-index FieldTree idiom.⚠️ Inventory drift, second sighting: the lib holds five MORE Reactive Forms components no wave inventory listed (role-create-dialog, user-roles, user-federated-identity, user-list, user-password-reset) — like geography-facts in batch 2. Wave 2 will need an addendum batch for stray Reactive surfaces; I'll do a repo-wide
FormBuilder|formControlNamecensus when the ngModel ledger closes so the epic ends at true zero.Wave-2 ledger: 14/63. Next: dev-tools libs (~16 templates).
E5 wave 2 batch 4 open — dev-tools (small nine) + Monaco
[(value)]spikersoft-angular#488. Infrastructure piece: MonacoEditorComponent now has a signal-native
valuemodel() beside its CVA — editor panes shouldn't need FormsModule; the ngModel path stays for unmigrated callsites. Nine templates migrated (blockly, diagram, duckdb, base64, both x86 panels, ipv4, quick-type main, save-name dialog); four new NG8022 variants for the playbook ([disabled]bindings → schemadisabled(),nameattr,maxlength).Wave-2 ledger: 23/63. Remaining: dev-tools heavy seven (batch 5: obfuscation modal 48 bindings, qr-code 18, csv/json conversions 28, options modal 14, reg-ex 8, image-to-avif 8) → art-studio (7) → marks-site (4) → misc app screens → stray-Reactive addendum census.
E5 wave 2 batch 5 open — dev-tools cluster COMPLETE (stacked)
spikersoft-angular#489 — the heavy seven (~124 bindings). Highlight: the two csv/json converters replaced fifteen per-field handlers with one tracking
effect(() => validateAndConvert()). Both quick-type settings modals became model+FieldTree with numeric bounds in schema.⛓️ Merge order: #488 first, then #489 (it's stacked on #488's Monaco
[(value)]wrapper; base is the batch-4 branch). Reminder: #488's earlier CI red was the verified runner-OOM — rerun on the same commit went green.Wave-2 ledger: 30/63. Remaining: art-studio (7) → marks-site (4) → misc app screens (~14: voxel block-editor ×2, chat, book-upload, epub annotation, git-playground, cpu-dashboard, map-tracker, ai-assistant, language-runner, clang videos pane, save-content-dialog, js-formatting-options, fundraiser ×3, sponsor ×3, blog dialog, games-clue-for-sql, trellis-3d) → stray-Reactive census addendum.
E5 wave-2 batch 6 up: art-studio — PR spikersoft-angular#490.
Six templates migrated; the seventh inventory entry (art-asset-viewer) was a grep false positive (
isLoadingModelcontains thengModelsubstring), so the true wave-2 denominator is 62, and the ledger now stands at 36/62 with the admin, keycloak-admin, dev-tools, and art-studio clusters all at zero ngModel.Notables from this batch:
form(signal)conversions with the min/max/maxlength attrs moved into schemas (the recurring NG8022 class).[value]+(input)— documented[formField]exceptions, same category as batch 5's CSV-list fields.[maxlength]property bindings only compile via FormsModule's validator directive; once FormsModule leaves the imports they go NG8002 → convert to[attr.maxlength].All local gates green (full build, full app suite 2065 tests, lib test+lint, fixers). Next: marks-site (4 templates), then the misc app screens, then the repo-wide stray-Reactive census to close the wave at true zero.
E5 wave-2 batch 7 up: marks-site — PR spikersoft-angular#492. Ledger: 40/62.
marks-site is now FormsModule-free. The three entity-array editors (address / generator / propane-tank) are documented native-binding conversions rather than formField: they mutate DTO entries in place on the parent-owned customer object, and the parent's Save sends that whole object relying on those mutations — Signal Forms' immutable, component-local writes would fork that data flow and drop sibling-tab edits on save. A
numberFromInputhelper preserves ngModel's empty→null semantics on numeric inputs, and nullable numerics bind viavalue="{{ x }}"interpolation (renders null as empty). marks-customer keeps its Reactive form per the locked ngx-mask decision, but its standalone customerType radio ngModel is gone too.All local gates green (build first-pass, 2065-test app suite, lint, fixers). Remaining: the misc app-screens batch(es) (~22 templates), then the repo-wide stray-Reactive census.
E5 wave-2 batch 8 up: five feature libs — PR spikersoft-angular#493. Ledger: 49/62.
blog moderation-dialog, the fundraiser trio, clue-for-sql, the sponsor trio, and trellis-3d-generator are all FormsModule-free. Mostly straight
form(signal)conversions; handler-driven controls (sanitizing inputs, slider thumbs, native selects with number ids) documented as native-binding exceptions. Two bonus finds: a latent double-write race on trellis's wireframe toggle (ngModel + (change) both flipping the same signal) now has a single writer, and three blog components carried dead FormsModule imports — stripped.All local gates green (intermediate builds caught two conversion slips, five-lib test+lint 183 tests, full build, 2065-test app suite, lint, fixers).
Remaining: platform/shared/game libs (~6 templates: wasm-voxel ×2, clang videos pane, language-runner, js-formatting-options, save-content-dialog) → app screens (~7) → repo-wide stray-Reactive census.
E5 wave-2 batch 9 up: game/platform/shared libs — PR spikersoft-angular#494. Ledger: 55/62 —
libraries/is now completely ngModel/FormsModule-free.wasm-voxel's two block editors (in-place entity mutation with engine-refresh hooks → native bindings, matching their sliders which were already native), language-runner (both monacos onto the wrapper's
[(value)]— the CVA path there is now retired), clang videos pane, js-formatting panel (store-setter native bindings;@forselect options need[selected]— select-level[value]races option creation on first render), save-content-dialog.All gates green: build first pass, five-lib test+lint (245 tests), full app suite (2065), lint, fixers.
Final stretch: the seven app screens (voxel wrapper hits are done — remaining are chat, book-upload, cpu-dashboard, epub annotation-dialog, git-playground, map-tracker, ai-assistant message-input), then the stray-Reactive census closes the wave.
E5 wave-2 ngModel migration: COMPLETE (pending merges). Batches 10+11 up: PRs #495 (final seven app screens) and #496 (census).
Ledger correction from the census: the inventory was built from
.htmlfiles, so six components with inline templates never made it in (program-library, robot-programming-panel, application-review, staff-issues' two embedded dialogs, delete-account-dialog, sql-theme-picker). True denominator was 68, and all 68 are now converted across batches 1–11. #496 also strips 17 verified-dead FormsModule imports (day-scheduler deliberately kept — bare<form>needs a submit guard before its NgForm can go).Open PRs awaiting merge: #492 (marks-site), #493 (feature libs), #494 (platform/shared/game libs), #495 (app screens), #496 (census). All five are independent branches off master, each fully gated locally (build, 2065-test app suite, per-lib tests, lint, fixers). Once merged, the repo is at zero ngModel.
Remaining E5 work after this wave (separate follow-up series, not ngModel debt): the stray-Reactive migrations — 10 keycloak-admin components, dev-tools jwt, interests-management, calendar event-modal, landing-page, geography-explorer — plus the locked ngx-mask trio which stays Reactive until ngx-mask supports Signal Forms, and day-scheduler's guarded FormsModule.
E5 Reactive follow-up series started. R1 up: keycloak-admin small six — PR spikersoft-angular#497.
role-create-dialog, user-password-reset, user-federated-identity, user-list (debounced search →
toObservable(model); itsdistinctUntilChanged()was a reference-compare no-op and is dropped), user-roles (client select now writes the resource-driving signal directly,selectedClientIdmiddleman deleted; the two search fields turned out to be bound-but-never-consumed — preserved as inert), and user-information (dynamic attributes Record via[formField]="userForm.attributes[key]", rootdisabled()schema replacing the parent-triggered enable/disable, soupdateFormState()is gone). Plus the landing-page dead stepper FormGroups from the census.New playbook entry:
[readonly]is another NG8022-forbidden binding on formField nodes (and redundant under adisabled()schema).All gates green: intermediate builds, keycloak-admin test+lint (147), full app suite (2065), lint, fixers. Specs rewritten to the field API where they drove the old controls.
Next: R2 — keycloak-admin heavy trio (client-create-dialog, client-edit-dialog, realm-settings — 34 controls), then R3 — app screens (interests-management, calendar event-modal, geography-explorer, dev-tools jwt). After R3,
FormBuilder/formControlNameremains only in the locked ngx-mask screens.R2 up: keycloak-admin heavy trio — PR spikersoft-angular#498. With R1+R2 merged, keycloak-admin is 100% Signal Forms.
Client dialogs (17-field shared model, required+pattern schema), realm-settings (34-field model,
disabled(path.realm)replacing the disabled-control config). Notable latent-bug fix: Reactive.valueexcludes disabled controls, so realm-settings has been sendingrealm: undefinedon every save (masked by Keycloak keying updates off the URL) — the model form now sends the real value.Gates green: build, keycloak-admin 147 tests + lint, full 2065-test suite, lint, fixers.
Next: R3 — the last four Reactive app screens (interests-management, calendar event-modal, geography-explorer, dev-tools jwt).
R3 up — the Reactive→Signal Forms series is COMPLETE: PR spikersoft-angular#499 (interests-management, calendar event-modal, geography-explorer, dev-tools jwt).
E5 end state once #498 and #499 merge:
<form>needs a submit guard first)Highlights from R3: event-modal is the workspace's first datepicker/timepicker on
[formField](Signal Forms drives their CVAs) and drops itsvalueChanges→markForCheckOnPush hack; jwt's paralleljwtToken/secretsignals becamecomputed()views of the model after a naive mirror-effect port looped (decode pretty-prints into the model → mirror clobbers the token — the spec caught it, which is exactly what the gates are for). One process slip, self-reported on the PR: the R3 commit briefly went up with a red event-modal spec because my gate chain short-circuited; fixed in the follow-up commit and the branch head is fully green (2065 tests).E5 R4 up — the census's third blind spot, and the true finish line: PR spikersoft-angular#500.
The FormBuilder/formControlName regex never covered standalone
new FormControl()+[formControl]. Post-merge sweep found four users (reader book-search-panel's debounced search, marks-customer-list's filters, dev-tools json-storage-selector-modal's autocomplete, marks-service-contract's tab-index value boxes) — all converted — plus three more dead FormsModule imports including day-scheduler, whose pointless bare<form>is unwrapped, retiring the last documented holdout.E5 exit state once #500 merges: zero ngModel · zero FormsModule · zero Reactive forms except marks-customer (ngx-mask lock) and explanatory comments. Census lesson recorded for posterity: sweep all THREE idioms —
ngModel(word-boundary, .ts AND .html for inline templates),FormBuilder|formControlName, andnew FormControl|[formControl].All gates green (build, touched-lib tests, 2065-test app suite ×2 with exit codes checked, lint, fixers).
E8 spike delivered — the epic's final effort: PR spikersoft-angular#501
Findings,
@Service: stable@publicApiin core 22.0.6, with exactly the shapes we need —@Service()auto-provided (replaces@Injectable({providedIn:'root'})),{autoProvided:false}for providers-list services,{factory}for useFactory. Core also shipsprovideIdleServiceWithfor tuninginjectAsyncidle-prefetch if E6's loaders ever need it. The workspace generator doesn't scaffold services, so the "generator default" from the plan materializes as a mirrored rule doc: new services use@Service(), no sweep of the ~213 existing (locked).Findings, Angular Aria:
@angular/aria22.0.4 is already a dependency and already piloted — the x86 playground's mobile debugger tabs run on@angular/aria/tabs. The package ships eight headless, signal-based primitives (accordion, combobox, grid, listbox, menu, tabs, toolbar, tree), each with a testing harness. Spike inventory of hand-rolledrole=/keydown widgets worth opportunistic migration (recorded in the rule): clang playground shell (9 role attrs), reg-ex (8), info-vault (6), language-runner, sql-runner, js-debug-vars, art-pipe shell — and the cleanest single example, event-modal's color picker (role="radio"+ three keydown handlers per swatch → listbox/radio primitive). Recommendation: adopt for new widgets + opportunistic rework, no sweep; Material components stay Material. WebMCP ban restated.Epic status: ALL EFFORTS DELIVERED
--skip-nx-cacheruns to ~2026-08-03) · E1c playwright plugin ✅ · E2a generator+typecheck ✅ · E2b test backfill ✅ (0 libs without a test target today)Awaiting merge: #500 (forms-census finisher) and #501 (this spike). After those, the only deliberately-open threads are: marks-customer stays Reactive until ngx-mask ships Signal Forms support, and dropping
--skip-nx-cachewhen the cache trust window closes. Suggest keeping the epic open until both PRs land, then closing it.Epic status roll-up — audited the E-series against
origin/master. This epic is close to done and its remaining items are small, which the 27-comment thread makes hard to see at a glance.@Service/@angular/ariaconventionsE0/E3:
main.yml:387setsNX_SELF_HOSTED_REMOTE_CACHE_SERVER=https://nx-cache.spikersoft.comwith the token pulled from OpenBao at:399(secret/ci/angular/nx-cache). The locked MinIO-backed decision is live.E1a/E2a: confirmed while auditing #824 (now closed). PRs run
nx affected -t lint/-t testagainstorigin/masterwith fullrun-manyon pushes; the per-projecttypechecktarget inferred bytools/workspace-pluginreplaced the unusable roottsc --noEmit. Worth recording explicitly: that means #664 was routed around rather than resolved — the root typecheck is still unusable, but nothing depends on it now.E5 is one file from done.
git grep -l "ngModel"across all app and library templates returns exactly one:libraries/features/art-studio/src/lib/art-asset-viewer/art-asset-viewer.component.html. Notably that is not one of the three documented ngx-mask blockers — onlymarks-customer.component.htmlstill carriesngx-mask, so the blocker set has shrunk too. Worth checking whether the art-asset-viewer one is a genuine blocker or simply missed by the wave.E7: no
server.ts,@angular/ssrorprerenderreferences remain inprojects/spikersoft/project.json.E8 is behaving as the locked decision intended — 3 files use
@Service()and 5 import@angular/aria. Both were scoped as "new code adopts, no sweep", so low counts are the expected outcome, not a gap.What's genuinely left, and it's outside the E-series:
^undefinedpeers (readsdependencies, but framework deps moved todevDependenciesat E0) plus a second bug I found:tslibalready carries its caret, so the template emits^^2.8.1. All three peer entries are malformed. Only one commit has ever touched that file.build-storybookin CI, so it has no rot detection.settle().None of those blocks the modernization itself. Suggested: finish the one ngModel file, then either close this epic and let #742/#728/#830 stand alone, or explicitly re-scope it to include them — right now they're orbiting the epic without being tracked in it.