Files
integreat/.claude/skills/ssr-form-migration/reference/scorecard.md
Bryce 4b2a3e53dd refactor(ssr): Phase 7 — migrate Invoice Pay onto the engine; prove the cross-step merge
Invoice Pay is the first GENUINE multi-data-step wizard, and migrating it exercises
the engine's central abstraction for the first time: choose-method collects
{:bank-account :method}, payment-details collects {:invoices :check-number
:handwritten-date :mode}, and the engine's get-all MERGES the two independent step
payloads for the per-method pay (handwrite-check transacts a pending check; the
others go through print-checks-internal). This is exactly the mechanism the Phase-6
adversarial review flagged as unproven.

What changed
- Deleted the 3 wizard records (PayWizard / ChoosePaymentMethodModal /
  PaymentDetailsStep), MultiStepFormState, the EDN snapshot, and the step-params[...]
  prefix. Replaced with pay-wizard-config (init-fn builds read-only :context;
  two steps; done-fn = pay!) driven by wizard2.
- De-cursored the payment-details amounts grid (fc/cursor-map -> explicit
  (map-indexed) over :context :invoices with path->name2 names).
- The bank-account cards' method controls now post {bank-account, method,
  direction:next} straight to the engine submit-route (was a bespoke navigate route).
- Routes 3 -> 2: open-pay-wizard (GET), pay-step (every transition); the
  pay-wizard-navigate route is deleted.
- Used the post-review engine primitives: :open-response (modal wrap), nav-footer
  (with new :save-label "Pay"), auto nav-field stripping (flat decode, no allowlist),
  Enter guard.

invoices.clj falls fully off the framework: Invoice Pay was the last mm/fc user
(bulk-edit went in Phase 5), so fc/ 0, mm/ 0, defrecord 0, step-params 0 — and the
multi-modal / form-cursor / malli.util requires are removed.

Gotcha discovered + documented: wizard session data must be EDN-safe (the cookie
session store has no clj-time readers), so the date default is computed in render,
not stored in context.

Verification: invoice-pay spec 3/3 (the merge end-to-end); full suite 58/58; load-file
clean; cljfmt clean. Skill fed: scorecard row (merge proven; whole-file zeroing) +
the EDN-session-safety gotcha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:59:24 -07:00

18 KiB
Raw Blame History

Quality scorecard (the ratchet)

Cheap to measure (grep -c, wc -l, clj-kondo), recorded before/after each migration in the commit message and in the results table below. No metric may regress for the touched modal without a written exception in gotchas.md. These are directional evidence, not targets to game — always paired with the e2e parity gate.

Heuristics

# Heuristic Measure Target
1 Faked cursor positions (not cursors themselves) grep -cE 'with-cursor|MapCursor\.' re-roots + grep -c 'defn.*-no-cursor' → 0 (top-rooted cursors are fine)
2 Implicit state merges (snapshot/cursor) count merge sites → 0 (forms); explicit put-step only (wizards)
3 Branching complexity clj-kondo, or count cond/condp/case/nested if + max depth net ↓
4 Lines of code wc -l on the modal's file(s) net ↓
5 Reuse / cross-form similarity cookbook components reused; duplicated-block count reuse ↑, dup ↓
6 Route count count routes for the modal → 2 (+1 for add-row)
7 OOB swaps grep -c hx-swap-oob → 0 unless a justified disjoint-region case is documented
8 Attribute consistency mixed :x-/"x-" encodings in migrated template → 0

How to measure (copy/paste)

F=src/clj/auto_ap/ssr/<modal>.clj
echo "LOC                $(wc -l < $F)"
echo "no-cursor twins    $(grep -c 'defn.*-no-cursor' $F)"
echo "faked-cursor roots $(grep -cE 'with-cursor|MapCursor\.' $F)"
echo "snapshot merges    $(grep -c ':multi-form-state :snapshot' $F)"
echo "branch forms       $(grep -cE '\(cond |\(condp |\(case |\(when-not ' $F)"
echo "hx-swap-oob        $(grep -c 'hx-swap-oob' $F)"
echo "mixed string hx-   $(grep -cE '\"hx-[a-z]' $F)"
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc

Results

Each migration appends one row (after-numbers), referencing the before in the diff.

Phase Modal LOC Routes no-cursor twins faked roots snapshot merges OOB mixed hx- cookbook reused / added
1 (baseline) Transaction Edit transaction/edit.clj 1608 ~12 1 2 ~75 0 8 — / seeded 7 entries
2 Transaction Edit transaction/edit.clj 1593 ~5 0 0 0 round-trip 0 8 (shared) location-select / 1 Selmer
2-final Transaction Edit (full Selmer + wizard removed) 1548 5 0 0 0 0 0 full sc/* lib / ~30 partials
3 Transaction Bulk Code transaction/bulk_code.clj 506 (was 420 — see exception) 3 0 0 0 0 0† reused all of Phase-2's sc/* lib + account-typeahead*/location-select* + edit-modal/transitioner chrome / added sc/select

† The one "hx-..." string hit is a response-header map ({"hx-trigger" "refreshTable, reset-selection"}), not a mixed attribute encoding. mm coupling 19→0, wizard records 3→0, step-params 10→0 (the 2 hits are comments), Hiccup-in-render → 0 except the shared com/success-modal (heuristic-9 exception, as in Phase 2). | 4 | POS Sales Summary pos/sales_summaries.clj | 732 (was 790) | 6 modal | 0 | 0 | 0 | 1✦ | 0✦ | reused sc/* lib + edit-modal/transitioner chrome / added the inline click-to-edit account-cell + manual-items patterns |

✦ The residual 1 hx-swap-oob and the "hx-..." string hits all live in the grid page code (the grid-page render lambdas + the filters form + the submit response-header map) — none are in the migrated modal render path, which is 100% Selmer. defrecord count 0 (all 4 wizard records gone), fc/ cursor refs 51→0, mm coupling 20→0, step-params 27→0 (2 comments). LOC dropped (this wizard held real custom code, unlike bulk-code's thin shell). Two pre-existing bugs fixed (per the user's call): the "New Summary Item" add button (was throwing newRowIndex is not defined) and the dead totals/balance display.

New heuristics introduced at 2-final (full Selmer)

# Heuristic Measure Target
9 Hiccup HTML tags in the render path grep -cE '\[:(div|span|p|a|button|input|h[1-6]|ul|li|label|select|option|t(able|head|body|r|d|h)|form|svg|template)' over the modal's render fns → 0 (success-modal confirmation dialogs may keep the shared Hiccup component)
10 mm wizard coupling grep -c 'mm/' the modal file + grep -c 'defrecord.*Wizard|ModalWizardStep' → 0 for a single-step modal

Phase 2 progress. Achieved with parity held (swap spec 6/6, transaction-edit spec 8/8, full suite 38 pass / 1 unrelated fail / 0 skip, up from 30/2/7):

  • deleted the dead *-no-cursor* twin (no-cursor 1→0);
  • de-faked the simple-mode cursor (faked roots 2→0) via explicit data + explicit field names (account-field-name) + explicit error lookup — the render-fn rewrite the with-field-default shortcut couldn't do;
  • collapsed the 5 manual-coding operation routes into one edit-form-changed dispatcher (routes ~12→~5; the operations are now pure apply-* fns);
  • fixed a real production bug (:mode → 500 on every advanced manual save);
  • greened transaction-edit.spec.ts (8/8) and matured the skill.

Phase 2 complete. The wizard→plain-form rewrite removed the snapshot round-trip (heuristic 2 → 0) and the first interactive component (location-select) is migrated to a Selmer template (selmer-conventions.md validated). Remaining for later phases: drop the now-thin mm/ModalWizardStep protocol wrappers, and the cross-cutting Phase 11 Selmer sweep of the shared com/typeahead/com/select/com/button-group-button (those shared call sites hold the last 8 mixed @/:-attr offenders; they clear when the shared components move to Selmer — not a single-modal task, per Open decision 2).

Phase 2-final — full Selmer + wizard removed. Every component the modal renders through was ported to a Selmer partial under resources/templates/components/ with a thin Clojure wrapper in auto-ap.ssr.components.selmer (sc/*); the modal's own structure lives under resources/templates/transaction-edit/. The mm wizard abstraction (EditWizard/LinksStep records, MultiStepFormState, step-params[...] field names, wrap-wizard/wrap-decode-multi-form-state middleware) was deleted — there was only ever one step, so it was pure overhead. Result: heuristic 8 (mixed hx-) and 9 (Hiccup in render) and 10 (mm coupling) all → 0; the edit-wizard-navigate route is gone (routes 5). Parity held: swap spec 6/6, transaction-edit spec 8/8, full suite 38 pass / 1 pre-existing unrelated fail (serial, fresh seed). The only Hiccup left in the file is the post-save com/success-modal confirmation dialogs (terminal, shared component — out of the form's render path). See form-vs-wizard.md (drop-the- wizard test), selmer-conventions.md (composition mechanics), and gotchas.md (stray-field decode leak; jetty reload staleness).

Phase 3 — Transaction Bulk Code (first cold apply of the mature skill). Single-step form wearing a full wizard costume (BulkCodeWizard/AccountsStep, MultiStepFormState, the step-params[...] prefix, the old find * location swap). Migrated to a plain form by mirroring Phase 2 — and it was mostly reuse: the entire sc/* Selmer component library, account-typeahead*/location-select*, and the edit-modal/transitioner chrome were imported wholesale; the only new shared component was sc/select (the status dropdown — location-select.html generalized). Parity held: bulk-code spec 13/13, full suite 39/39 (up from the Phase-2 baseline of 3839). mm coupling 19→0, snapshot merges 4→0, wizard records 3→0, routes 4→3 (open / submit / form-changed — the per-op new-account + vendor-changed routes folded into one form-changed op dispatcher), the location swap moved off find * onto explicit #account-location-<index> + hx-select.

The one regression — LOC 420→506 (documented exception, see gotchas.md). Unlike edit (whose wizard held real custom code), bulk-code's wizard was a thin shell that delegated almost everything to mm/* defaults (default-render-step, default-render-wizard, submit-handler, open-wizard-handler). Ripping the wizard out moves that previously-shared plumbing into the file as explicit render/decode/submit/handler code. The trade is intended: every other heuristic improved and the modal is now self-contained and wizard-free. New patterns added to the cookbook: the selection-as-ids[] round-trip (resolve the non-editable selection to a concrete id vector at open, ride it in hidden fields — the bulk analog of edit's single db/id), and the :id-keyed vendor typeahead (a value-bound hidden must be keyed or its posted value goes stale across a whole-form swap).

Phase 4 — POS Sales Summary (first modal with no prior test coverage). The largest migration so far and the first that required building the parity gate first: the modal had zero e2e/clj tests and the test server seeded no POS data, so the work began by seeding a balanced sales summary + writing a 7-test characterization spec (committed separately, ahead of the rewrite). Then the standard wizard→plain-Selmer migration: MainStep/EditWizard + MultiStepFormState deleted, the 51 fc/ cursor refs de-cursored into explicit data + Selmer, step-params dropped, the EDN snapshot replaced by flat wrap-decode/wrap-derive-state (with a db/id-keyed item merge so the read-only fields the form doesn't post — ledger-side, amount — survive a re-render). The inline click-to-edit account cell (pencil → typeahead editor → check/cancel) was preserved as three small targeted .account-cell-swap routes (a distinct feature, not folded into the form-changed dispatcher). LOC 790→732 (net ↓ — a fat wizard, opposite of bulk-code).

Characterize-then-fix. Writing the gate surfaced two pre-existing bugs: the "New Summary Item" button threw newRowIndex is not defined (dead since forever) and the totals/balance display was dead code (malformed Hiccup that discarded its labels). The spec first documented them as broken (never assert a bug as working); then, on the user's call, the migration fixed both — add-item is now a whole-form-swap op=new-item adding an editable manual row, and a proper #summary-totals block shows running Total + Balanced/Unbalanced (a Rule-4 targeted swap on manual amount edits). The spec was updated to assert the fixed behavior. New cookbook entries: the inline click-to-edit cell and the db/id-keyed item merge for partially-posted rows.

Phase 5 — Invoice Bulk Edit (cold apply in a 1812-line shared file). Structurally Phase 3's bulk-code applied to invoices (selected entities → expense-account rows: account/location/percentage), so it was near-pure reuse: bulk-code's flat-state plumbing (ids round-trip, wrap-bulk-state, schema/decode) + edit's account-totals-tbody for the live totals. BulkEditWizard/AccountsStep + MultiStepFormState deleted, step-params dropped, the rows de-cursored to Selmer with the explicit-id location swap, bulk-edit routes 5→3 (the new-account + total + balance routes folded into one form-changed op dispatcher + the sibling-<tbody> totals swap). Implemented the dead TOTAL/BALANCE display (the wizard had them commented out with a duplicate id="total") as a #expense-totals sibling-<tbody> refreshed by a Rule-4 percentage-keyup swap. Parity held: invoice-bulk-edit spec 5/5, full suite 50/50.

Editing a wizard buried in a large shared file: the clojure-mcp structural tools (clojure_edit / replace_sexp) reformat the whole file — here that was a spurious 650-line whitespace diff that would bury the real change. For a surgical migration inside a big multi-modal file, use the text-based Edit tool instead (the AGENTS.md "absolutely necessary" carve-out), then load-file + cljfmt to verify. The resulting diff was fully contained to the requires + the bulk-edit region.

Repeated-row target-selector convention — settled (the Phase 5 exit criterion). Across edit / bulk-code / sales-summary / invoice-bulk-edit the convention converged on: explicit per-row ids (#account-location-<index>, #account-row-<index>) for a cell-local swap (Rule 2), and a single stable-id sibling-<tbody> (#account-totals / #expense-totals) for running totals (Rule 4) — not data-attribute selectors or a form-path→selector helper. Per-row ids are generated from the row index the form already uses for field names (path->name2), so server and markup agree by construction. Whole-form swap (Rule 3) covers structural changes (add/remove row). This is now the cookbook default; see swap-doctrine.md.

Phase 6 — the wizard engine, and its first real modal (Transaction Rule). The inflection phase. (a) Engine (6a, committed separately): wizard-state + wizard2, the Django formtools SessionStorage model, REPL-proven before any modal touched it. (b) First real modal (6b): the Transaction Rule wizard (edit step + read-only test/preview step) migrated onto the engine and fully de-cursored like Phases 2-5. Scorecard (admin/transaction_rules.clj): fc/ cursor refs 82 -> 0, mm/ coupling 20 -> 0, defrecords 3 -> 0 (EditModal / TestModal / TransactionRuleWizard all gone), LOC 1000 -> 964, the 4 wizard routes (open/navigate/save + per-dialog) collapse to 2 (open-rule-wizard for new+edit, save-step for every transition). Parity held: rule spec 4/4, full suite 55/55.

The engine generalizes even for a one-data-step "wizard". Transaction Rule is edit + a read-only preview of the same entity, not two independent data steps — so it exercises the engine's render / navigation / :all-data-preview path but not the cross-step merge (that waits for Phase 7's Invoice Pay). The test step's :render reads :all-data (the engine's get-all), which here is just the edit step's rule — so the formtools "combine at the end" mechanism is exactly what feeds the preview table. Nav is the engine's direction field (plain submit buttons name="direction" value="next|back|submit"), so the per-step navigate route is deleted.

Note (scope): the de-cursored edit step keeps com/* Hiccup leaf components rather than porting to sc/* Selmer partials — the modal's value was removing fc/ + mm/ and proving the engine, not re-templating its (conditional, Alpine-cross-field) layout. Hiccup-in-render (heuristic 9) is therefore a documented partial here; the leaf-component com/ -> sc/ swap is a mechanical follow-up. The Alpine cross-field dispatch wiring (clientId -> accountId -> location) was preserved verbatim — de-cursoring touched only the data plumbing.

Phase 7 — Invoice Pay: the engine's cross-step merge, finally proven. The first genuine multi-data-step wizard (every prior one was single-data-step wearing wizard costume, or edit+preview of one entity). Step 1 choose-method collects {:bank-account :method}; step 2 payment-details collects {:invoices :check-number :handwritten-date :mode}; the engine's get-all merges the two independent payloads for the per-method pay! (handwrite-check transacts a pending check; the others go through print-checks-internal). This is the exact mechanism the Phase-6 reviewer flagged as unproven — now exercised end-to-end (gate 3/3: choose-method renders the bank-account + methods → handwrite-check advances to details → check number + submit shows the completion modal). Conditional rendering by method (handwrite shows check-number, print shows date) lives in step 2's render, reading :method from :all-data.

The whole file falls off the framework. Invoice Pay was the last mm/fc user in invoices.clj (bulk-edit went in Phase 5), so the migration zeroed the file: fc/ cursor refs 0, mm/ 0, defrecord 0 (PayWizard + ChoosePaymentMethodModal + PaymentDetailsStep all gone), step-params 0 — and the multi-modal / form-cursor / malli.util requires were deleted outright. The pay wizard's 3 routes (open / navigate / submit) collapse to 2 (open = open-pay-wizard, every transition = pay-step); the cards post {bank-account, method, direction:next} straight to the engine submit-route instead of a bespoke navigate route.

Engine dividends from the review follow-up paid off here. This migration used the primitives the engine absorbed after Phase 6: :open-response (modal wrap, so open is one handler), nav-footer (with the new :save-label "Pay"), the auto nav-field stripping (the flat {bank-account, method} decode needs no allowlist), and the Enter guard — so the consumer is config + render + the per-method pay!, not framework plumbing.

New constraint discovered: wizard session data must be EDN-safe (the cookie store has no clj-time readers) — see gotchas.md. The de-cursored amounts grid stores the enriched invoice list in :context, which is fine at gate scale (1 invoice) but is the session-bloat risk the reviewer named; a leaner context (ids + amounts, re-query in render) is the follow-up if real payments carry many invoices.