- form-vs-wizard.md: the sub-editor pattern — modeling a parameterized sub-step (list ⇄ per-item editor with accept/discard/sort) on the linear engine as whole-form swaps driven by routes that mutate session step-data, with a pass-through step :decode that re-reads the list via a non-stripped `wiz` hidden. - scorecard.md: Phase 10 row (defrecord 9→0, multimethods→case, grid+schemas+ power-query preserved verbatim, blank-address recurrence, 71/71 green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
25 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.
Phase 6 — the wizard engine, and its first real modal (Transaction Rule). The inflection phase. (a) Engine (
6a, committed separately):wizard-state+wizard2, the DjangoformtoolsSessionStorage 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-wizardfor new+edit,save-stepfor 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:renderreads:all-data(the engine'sget-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'sdirectionfield (plain submit buttonsname="direction" value="next|back|submit"), so the per-stepnavigateroute is deleted.Note (scope): the de-cursored edit step keeps
com/*Hiccup leaf components rather than porting tosc/*Selmer partials — the modal's value was removingfc/+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-componentcom/ -> 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-methodcollects{:bank-account :method}; step 2payment-detailscollects{:invoices :check-number :handwritten-date :mode}; the engine'sget-allmerges the two independent payloads for the per-methodpay!(handwrite-check transacts a pending check; the others go throughprint-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:methodfrom:all-data.The whole file falls off the framework. Invoice Pay was the last
mm/fcuser ininvoices.clj(bulk-edit went in Phase 5), so the migration zeroed the file:fc/cursor refs 0,mm/0,defrecord0 (PayWizard + ChoosePaymentMethodModal + PaymentDetailsStep all gone),step-params0 — and themulti-modal/form-cursor/malli.utilrequires 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-methodpay!, 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.
Phase 8 — New / Edit Invoice (the conditional-:next, dual-purpose wizard)
auto-ap.ssr.invoice.new-invoice-wizard — the hardest modal in the app: one wizard that
both creates and edits invoices, with a conditional middle step (basic-details →
[accounts] → next-steps, where accounts is skipped on the default-accounts path), Solr
typeaheads for client+vendor, an async account-prediction fragment, and live expense-account
totals.
Finding: the OLD basic-details "Save" was broken. It hx-puts /invoice/new/navigate,
whose [:to {:optional true} …] query-schema 500s on empty query-params (the {}→nil
main-transformer quirk — see gotchas.md). Production uses the identical wrap-params, so
it was broken there too; the underlying create only worked when POSTed straight to
new-invoice-submit. So the Phase 8 gate (e2e/invoice-new.spec.ts) is an acceptance
gate, not a green→green characterization: red on the old code, green on the engine (whose
submit is a POST with no query-schema). The migration fixes a latent bug. The create
semantics (default → vendor default account, location-spread; customize → the posted grid;
edit → prefilled + updated row) were pinned via REPL before/around the migration.
Conditional :next is a one-liner ((if (= :customize …) :accounts :done)) replacing the
mm/CustomNext protocol + the broken 308-to-submit. Dual-purpose = one :init-fn branching
on a route :db/id; create-wizard! seeds :init-data as per-step step-data so edit opens
both steps populated. See form-vs-wizard.md.
Coupling outcome (the review's lens). The whole wizard collapses to config + render +
fragments: defrecord 4 → 0 (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps all
gone), mm/ 0, fc/ cursor refs 0, step-params[…] field names 0. The broken
new-wizard-navigate route is deleted (3 wizard-nav routes → the engine's open + submit);
the genuine async helpers (account-prediction, due-date, scheduled-payment-date,
location-select, expense-account total/balance, add-row) remain but were de-coupled from
multi-form-state — each now reads the posted flat form (+ ws/get-all for the one cross-step
value). next-steps stops being a wizard step and becomes the done-fn's returned modal (Pay
now / Add another / Close), matching the Phase 7 pay-success shape.
Verification: full e2e suite 61/61 (58 prior + 3 new: basic-details renders;
default-path create → next-steps; customize-path → accounts grid → create → next-steps); the
maybe-spread-locations unit test still 6/6; create semantics + edit prefill confirmed at the
REPL; dates ride as #inst so step-data is EDN-safe across the non-terminal step.
Phase 9 — New / Edit Vendor (5-step linear wizard)
auto-ap.ssr.admin.vendors — a five-step linear wizard (info → terms → account → address →
legal) plus a separate Merge dialog. Migrated onto the engine following the Phase 8 template.
Latent bug found + fixed (again): the old "Next" PUT /admin/vendor/navigat (note the
typo) carried a [:map [:db/id entity-id]] route-schema on a route with no :db/id path
param, so empty route-params {}→nil 500d every advance (same main-transformer quirk as
Phase 8's query-params). The engine's submit is a POST with no such schema → gone.
Coupling outcome: defrecord 5 → 0 (InfoModal / TermsModal / AccountModal /
AddressModal / LegalEntityModal + VendorWizard all gone), mm/ 0, fc/ cursor refs
0 (the wizard and the de-cursored Merge dialog), step-params[…] 0. Routes: the
broken navigate is deleted (open + submit + 3 add-rows + account-typeahead remain). The 5
step renders are plain data + path->name2 + a *errors* binding; the 3 repeated grids
(terms-overrides / automatic-payment / account-overrides) became add-row-handler + a
blank-row row render. The wizard timeline is preserved as a per-step side panel.
Engine refinements exercised: conditionless linear :next; :init-fn branches new
(empty) vs edit (entity split across the 5 steps' :init-data, which create-wizard! seeds
as per-step step-data so edit opens fully populated); per-step :validate via
mc/validate + me/humanize replaces the old wrap-ensure-step schema assertion;
vendor-step wraps handle-step-submit in try+ to surface create-time validation as a
4xx. Two new gotchas surfaced and are documented: the empty-step {}→nil decode trap and the
blank-nested-entity upsert error (see gotchas.md).
Verification: full e2e suite 65/65 (61 prior + 4 new: info renders + timeline;
create across all 5 steps persists; edit opens prefilled and a rename persists; a too-short
name blocks advancing). Create + edit semantics also confirmed at the REPL (incl. the
cookie-session EDN round-trip). maybe-spread-locations-style domain helpers untouched.
Phase 10 — New/Edit Client (the largest modal: 7 steps + a bank-account sub-editor)
Coupling outcome: defrecord 9 → 0 (InfoModal / MatchesModal / ContactModal /
BankAccountsModal / IntegrationsModal / BankAccountModal / CashFlowModal /
OtherSettingsModal + ClientWizard all gone), mm/ 0, fc/ cursor refs 0,
step-params[…] 0, bank-account-card/bank-account-form multimethods (dispatched on
(comp deref :bank-account/type)) collapsed to plain case on a data map. Routes: the
broken navigate + discard are deleted; four bank-account sub-editor routes added
(new/edit/accept/discard) + sort kept. The grid, both form schemas, and ~200 lines of
sales power-query export are preserved verbatim (stitched around the rewritten wizard
region rather than retyped).
The new pattern — a parameterized sub-step on a linear engine. The old
[:bank-account which] mm sub-step (open one account, Accept/discard/sort, back to the
list) doesn't map onto wizard2's flat step list. Modeled instead as a sub-editor of the
bank-accounts step: see form-vs-wizard.md ("Sub-editor"). Key moves: list + editor are
both whole-form swaps of #wizard-form; dedicated routes mutate :bank-accounts
step-data in the session via ws/put-step and re-render through wizard2/render-wizard;
the step's own :decode is a pass-through that re-reads the session list (via a wiz
hidden the engine doesn't strip) so Next never wipes the out-of-band list.
Fixes carried/!surfaced: new-vs-edit keyed off :db/id presence (engine always POSTs,
so the old PUT/POST split is gone); client + bank-account dates → #inst for EDN-safe
session; the blank-address trap recurs (empty Contact address posts blank fields →
all-nil db/id-less map → "tempid used only as value") — same blank-address? drop as
Phase 9. A long detour confirmed the REPL is direct-link-poisoned: validate migrations
against a fresh TEST_SERVER_PORT=… lein run -m auto-ap.test-server JVM, not the REPL.
Verification: full e2e suite 71/71 (65 prior + 6 client-wizard: new dialog + timeline; edit prefill w/ disabled code; bank-accounts card + add affordance; editor open/discard; accept-merge; edit→save round-trip). Engine flow + accept + pass-through + edit init also confirmed at the REPL.