Migrates the Invoice Bulk Edit modal off the wizard to a plain Selmer form, building on the parity gate. Structurally Phase 3's bulk-code applied to invoices (selected entities -> expense-account rows), so near-pure reuse of bulk-code's flat-state plumbing + edit's account-totals-tbody. What changed - Wizard removed: deleted BulkEditWizard/AccountsStep records, MultiStepFormState, the step-params[...] prefix, the EDN snapshot, and all mm/* for this modal. Replaced with a plain handler + flat wrap-bulk-state (decode straight into bulk-edit-schema, no snapshot). - Selection-as-ids round-trip: the non-editable invoice selection is resolved to a concrete not-locked id vector at open and ridden back in hidden ids[] fields (the bulk analog of edit's single db/id) -- no filter re-query. - De-cursored bulk-edit-account-row* to Selmer (sc/*), explicit-id location swap (#account-location-<index>, replacing the old find * swap), reusing tx-edit/location-select*. - 100% Selmer modal render path; the surgical edit was done with the text-based Edit tool (the clojure-mcp structural tools reformat the whole 1812-line file), so the diff is contained to the requires + the bulk-edit region. - Routes 5 -> 3: GET bulk-edit, PUT bulk-edit-submit, POST bulk-edit-form-changed (one whole-form op dispatcher folding the old new-account route). Implemented the dead totals - The wizard's TOTAL/BALANCE percentage rows were commented out (#_(...)) with a duplicate id="total". Implemented as a #expense-totals sibling-<tbody> refreshed by a Rule-4 percentage-keyup swap (the new-account + total + balance routes all fold into form-changed / the sibling-tbody). Scorecard delta (bulk-edit modal): wizard records 2->0, bulk-edit routes 5->3, step-params/fc-cursor (modal) ->0, location swap find *-> explicit-id, totals dead->implemented. Verification: invoice-bulk-edit spec 5/5 (incl. add-row, save, validation, the implemented totals); full Playwright suite 50/50; cljfmt clean; diff confined to the modal region. Skill fed: scorecard row + settled repeated-row target-selector convention; gotcha (structural tools reformat large files -> use text Edit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
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 thewith-field-defaultshortcut couldn't do;- collapsed the 5 manual-coding operation routes into one
edit-form-changeddispatcher (routes ~12→~5; the operations are now pureapply-*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.mdvalidated). Remaining for later phases: drop the now-thinmm/ModalWizardStepprotocol wrappers, and the cross-cutting Phase 11 Selmer sweep of the sharedcom/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 inauto-ap.ssr.components.selmer(sc/*); the modal's own structure lives underresources/templates/transaction-edit/. Themmwizard abstraction (EditWizard/LinksSteprecords,MultiStepFormState,step-params[...]field names,wrap-wizard/wrap-decode-multi-form-statemiddleware) 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; theedit-wizard-navigateroute 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-savecom/success-modalconfirmation dialogs (terminal, shared component — out of the form's render path). Seeform-vs-wizard.md(drop-the- wizard test),selmer-conventions.md(composition mechanics), andgotchas.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, thestep-params[...]prefix, the oldfind *location swap). Migrated to a plain form by mirroring Phase 2 — and it was mostly reuse: the entiresc/*Selmer component library,account-typeahead*/location-select*, and theedit-modal/transitionerchrome were imported wholesale; the only new shared component wassc/select(the status dropdown —location-select.htmlgeneralized). Parity held: bulk-code spec 13/13, full suite 39/39 (up from the Phase-2 baseline of 38–39). mm coupling 19→0, snapshot merges 4→0, wizard records 3→0, routes 4→3 (open / submit /form-changed— the per-opnew-account+vendor-changedroutes folded into oneform-changedop dispatcher), the location swap moved offfind *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 tomm/*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 singledb/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+MultiStepFormStatedeleted, the 51fc/cursor refs de-cursored into explicit data + Selmer,step-paramsdropped, the EDN snapshot replaced by flatwrap-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-swapop=new-itemadding an editable manual row, and a proper#summary-totalsblock 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'saccount-totals-tbodyfor the live totals.BulkEditWizard/AccountsStep+MultiStepFormStatedeleted,step-paramsdropped, the rows de-cursored to Selmer with the explicit-id location swap, bulk-edit routes 5→3 (thenew-account+total+balanceroutes folded into oneform-changedop dispatcher + the sibling-<tbody>totals swap). Implemented the dead TOTAL/BALANCE display (the wizard had them commented out with a duplicateid="total") as a#expense-totalssibling-<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), thenload-file+cljfmtto 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 aform-path→selectorhelper. 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; seeswap-doctrine.md.