Files
integreat/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md
Bryce 917b7f3857 docs: clarify cursors are fine; only faked positions are the smell
Reframe goal 2, the rationale (2.2), the render-function pattern (3.2), and
scorecard heuristic 1 so the target is top-rooted cursors. Cursors stay; what
we remove is faking a cursor to start deeper in the tree and the duplicate
*-no-cursor* variants that fakery forces.

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

33 KiB
Raw Blame History

SSR Form & Wizard Simplification — Migration Plan

Status: Planning / for execution by an agent or engineer. Owner: Bryce Type: Refactor (no user-facing behavior change; parity required).

This plan describes a series of low-risk migrations that make the server-side rendered (SSR) forms and wizards substantially simpler. It is self-contained: every concept needed to execute is stated here, illustrated with code snippets. The work is sequenced so each migration is small, reversible, and teaches a skill that makes the next migration cheaper.


1. Goals

  1. Render forms by re-rendering the whole form (or a precise, isolated fragment) over HTMX, using hx-select to choose elements, instead of mutating the DOM in place. This removes the class of bugs around stale state, lost focus/caret, and out-of-band patching.
  2. Root cursors at the top; never fake their position. Cursors are fine and stay — a render function may take an explicit data map or a cursor. What we remove is the practice of faking a cursor to start deeper in the tree to satisfy a partial render, and the duplicate *-no-cursor* variants that fakery forces. The target: a cursor always begins at the top level of what the form consumes and walks down naturally from there. (Because the whole form is re-rendered each time, there is no longer any reason to fake a deep starting position.)
  3. Stop forcing single-step forms through wizard machinery. Most "wizards" are single-step; they become plain forms. Genuine multi-step flows use a small data-driven engine instead of protocols + middleware stacking.
  4. Render HTML with Selmer templates (Jinja-style) instead of Hiccup for the interactive, attribute-heavy components, so Alpine/HTMX attributes are first-class HTML rather than a mix of Clojure keywords and strings.
  5. Capture the migration method in a skill that is created after the first successful migration and extended by every migration thereafter.

Net effect target: large reduction in lines of code, route count, and branching complexity, with measurably more reuse across similar forms.


2. Why — the current pain (rationale)

2.1 In-place DOM mutation is fragile

Re-rendering only fragments and patching the rest (via morph or out-of-band swaps) means the server and the DOM can disagree. Keeping a focused input alive through a patch requires keying tricks and guards. Re-rendering the whole form and letting the typed value ride along in the form is simpler and correct, provided the input the user is typing in is never inside the region being swapped.

2.2 Faking cursor positions forces duplicate functions

A "form cursor" itself is fine. The pain comes from faking the cursor's starting position — rebinding the dynamic root deeper in the tree so a deeply nested render function can run against a fragment. That fakery is fragile and hard to follow, and it has spawned duplicate render functions: one that reads the faked cursor and one that takes plain params for the cases where the fake can't be set up.

;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
;; so it only works when *current*/*prefix* were rebound to point there first.
(defn account-row* [{:keys [value client-id]}]
  (com/data-grid-row
    (fc/with-field :transaction-account/account
      (com/data-grid-cell
        (account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
    ...))

;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
(defn account-row-no-cursor* [{:keys [account index client-id]}]
  ...)

Target: the cursor starts at the top of the form's data and walks down naturally; a row render either takes explicit row data or receives a cursor the caller advanced step-by-step from the root — never one teleported to a deep node.

2.3 Single-step forms wear wizard costumes

Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods), serialize an EDN snapshot with custom readers into hidden fields, and register 1020 routes with stacked middleware — all for a single-step form. That is pure overhead.

2.4 Hiccup makes Alpine/HTMX attributes ambiguous

The same attribute is sometimes a keyword and sometimes a string in the same file, and event handlers must be strings while structural Alpine attrs are keywords. There is no rule a reader (or an LLM) can rely on:

;; Both of these appear in one component file today:
:x-ref  "input"      ; keyword key
"x-ref" "hidden"     ; string key
:x-model "value.value"
"x-model" "search"
"@keydown.down.prevent.stop" "tippy.show();"  ; handlers must be strings
:x-init "..."                                  ; structural attrs are keywords

In a Selmer template the same markup is unambiguous plain HTML:

<input x-ref="input" x-model="value.value"
       @keydown.down.prevent.stop="tippy?.show()" />

3. Target state (the patterns, with snippets)

These four patterns are what every migration moves code toward. The skill (§5) holds the canonical, growing version of each.

3.1 Whole-form HTMX swap doctrine

Decide per interactive control, in this priority order:

  1. No request when the field affects nothing else. Its value rides along in the form and is read on submit.
    <!-- a memo / free-text field that influences nothing -->
    <input name="memo" />   <!-- no hx-* at all -->
    
  2. Targeted swap of a single isolated cell when a field's effect is purely local. Give the cell a stable id and keep it out of the typed input's subtree.
    <!-- selecting an account only changes the valid Location options -->
    <select name="accounts[0][account]"
            hx-post="/transaction/edit-form-changed"
            hx-target="#account-location-0"
            hx-select="#account-location-0"
            hx-swap="outerHTML" hx-trigger="changed">
    </select>
    <div id="account-location-0"> ...location options... </div>
    
  3. Whole-form swap when the change touches interdependent state (vendor, add/remove row, mode toggle, $/% radio). The form's hidden state rides along, so one swap keeps everything consistent — no out-of-band swaps.
    <form id="wizard-form"
          hx-post="/transaction/edit-form-changed"
          hx-target="#wizard-form" hx-select="#wizard-form" hx-swap="outerHTML">
      ...
    </form>
    
  4. Out-of-band (OOB) swap only for genuinely disjoint DOM regions — a global flash/toast, a nav badge, a modal mounted at the document root. If you are tempted to OOB something inside the same feature, that is a signal to restructure the DOM so the dependent element shares a common ancestor with the trigger, and use an ordinary swap. Example: put running totals in a sibling <tbody> so an amount edit can swap totals without replacing the amount input:
    ;; totals live in their own tbody, a sibling of the input rows
    (com/data-grid- {:rows ...
                     :footer-tbody [:tbody {:id "account-totals"} ...]})
    
    ;; the amount input swaps ONLY the totals tbody (never itself)
    [:input {:name "accounts[0][amount]"
             :hx-post "/transaction/edit-form-changed"
             :hx-target "#account-totals" :hx-select "#account-totals"
             :hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms"}]
    

Focus invariant (must always hold): the input the user is typing in is never inside the region its own request swaps.

Alpine components must survive swaps. Null-guard every reference that depends on Alpine/tippy being initialised, and key a component by its server-provided value so a server-driven change re-initialises it instead of preserving stale state:

;; null-guard:
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
;; key by current value so morph/replace re-inits on server change:
(assoc attrs :key (str id "--" current-value))

3.2 Render functions: explicit data, or a top-rooted cursor

One function, data in, markup out. The data can arrive as a plain map or via a cursor — as long as the cursor was rooted at the top of the form and walked down to here, never faked to start at this depth.

;; GOOD: pure, works everywhere, testable without setup
(defn account-row [{:keys [account index client-id amount-mode]}]
  (com/data-grid-row
    (com/hidden {:name (str "accounts[" index "][db/id]")
                 :value (or (:db/id account) "")})
    (com/data-grid-cell
      (account-typeahead* {:value (:transaction-account/account account)
                           :name  (str "accounts[" index "][account]")
                           :client-id client-id}))
    ...))
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
;; The top-level render walks the cursor; the row fn receives the dereferenced
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
(defn account-rows [accounts-cursor]
  (for [row-cursor (fc/each accounts-cursor)]   ; advanced from the root, not faked
    (account-row {:account @row-cursor :index (fc/index row-cursor) ...})))

The rule is about where the cursor starts, not whether you use one. If a caller already holds a top-rooted cursor, advance it and hand the row data (or the advanced cursor) to one render function. Never rebind the cursor to teleport to a deep node, and never keep a second *-no-cursor* copy of the markup.

3.3 Forms vs. wizards (and the data-driven wizard engine)

  • Single-step → plain form. Two routes: GET (render) and POST (validate

    • save). State is plain form fields + an entity id. No snapshot, no server state, no protocol.
    {::route/edit        (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
     ::route/edit-submit (fn [req] (validate-and-save req))}
    
  • Genuinely multi-step → data-driven engine. A wizard is data:

    (def vendor-wizard-config
      {:steps [{:key :info     :schema info-schema     :fields [...] :render render-info-step
                :next (fn [data] :terms)}
               {:key :terms    :schema terms-schema    :fields [...] :render render-terms-step
                :next (fn [data] :done)}]
       :init-fn (fn [req] {...})
       :submit-route "/admin/vendor/wizard/submit"
       :done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
    

    with a tiny engine (no protocols) and server-side state keyed by a UUID token:

    ;; state: an atom keyed by wizard-id (add a timestamp + TTL sweep)
    (defonce ^:private store (atom {}))
    (defn create-wizard! [init] (let [id (str (java.util.UUID/randomUUID))]
                                  (swap! store assoc id {:current-step (-> init :steps first :key)
                                                         :step-data {} :created-at (System/currentTimeMillis)})
                                  id))
    (defn update-step! [id k data] (swap! store update-in [id :step-data k] merge data))
    (defn get-all [id] (apply merge (vals (:step-data (@store id)))))
    
    (defn render-wizard [{:keys [wizard-id config request]}]
      (let [{:keys [current-step step-data]} (@store wizard-id)
            step (first (filter #(= (:key %) current-step) (:steps config)))]
        [:form#wizard-form {:hx-post (:submit-route config)
                            :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
         (com/hidden {:name "wizard-id" :value wizard-id})
         (com/hidden {:name "current-step" :value (name current-step)})
         ((:render step) (assoc request :step-data (get step-data current-step {})))]))
    
    (defn handle-step-submit [config request]
      (let [{:strs [wizard-id current-step]} (:form-params request)
            step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
            data (select-keys (:form-params request) (map name (:fields step)))]
        (if-let [errors (mc/explain (:schema step) data)]
          (render-wizard {:wizard-id wizard-id :config config :request (assoc request :errors errors)})
          (do (update-step! wizard-id (keyword current-step) data)
              (let [nxt ((:next step) data)]
                (if (= nxt :done)
                  (let [all (get-all wizard-id)] (swap! store dissoc wizard-id) ((:done-fn config) all request))
                  (do (swap! store assoc-in [wizard-id :current-step] nxt)
                      (render-wizard {:wizard-id wizard-id :config config :request request}))))))))
    

    Two routes per wizard: open (partial open-wizard config) and submit (partial handle-step-submit config).

3.4 Selmer templates

Interactive components render from Selmer templates with plain-HTML attributes. Selmer composes via {% include %} and {% block %}; an interop bridge lets a Selmer template embed Hiccup output (and vice versa) during the transition.

{# templates/components/typeahead.html #}
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
  <a class="{{ classes }}" x-ref="input" tabindex="0"
     @keydown.down.prevent.stop="tippy?.show()"
     @keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
    <span x-text="value.label"></span>
  </a>
  ...
</div>
;; render helper + interop bridge
(defn render [tpl ctx] (selmer/render-file tpl ctx))
(defn hiccup->html [h] (hiccup/html h))          ; embed hiccup inside selmer via {{ frag|safe }}
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]

4. Principles

  1. Strangler, not big-bang. New engine, Selmer renderer, and the swap doctrine live alongside the old code. Migrate one modal at a time behind its own route. Old machinery is deleted only when its last caller is gone.
  2. Simplest first. Each migration is small and reversible (one commit). Start with the already-proven modal, then the smallest fresh ones, and leave the largest/most complex for last — by which point the skill is mature.
  3. Skill-driven and self-reinforcing. After the first successful migration, distil the method into a skill (§5). Every subsequent migration reads the skill first and extends it last.
  4. Quality must measurably improve. Each migration records a scorecard (§6); no metric may regress for the touched modal.
  5. Behavior parity is proven by tests, not by reading (§7). The full e2e suite must stay green after every migration.

5. The skill: ssr-form-migration

When it is created: in Phase 1, immediately after — and distilled from — the first successful modal migration (the transaction-edit modal, whose whole-form swap implementation already exists and serves as the reference). The skill is not written speculatively; it encodes a method that already worked.

Where: .claude/skills/ssr-form-migration/ (matches the existing project convention, e.g. .claude/skills/testing-conventions/SKILL.md).

Structure:

.claude/skills/ssr-form-migration/
  SKILL.md                # the playbook (§8): classify → migrate → verify → record
  reference/
    swap-doctrine.md      # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening
    render-functions.md   # §3.2 explicit-data or top-rooted cursor; no faked positions
    form-vs-wizard.md     # §3.3 classification + the data-driven engine
    selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
    component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
    gotchas.md            # GROWS: stale $refs, key-by-value, wizard-id GC, coercion…
    test-recipes.md       # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id
    scorecard.md          # the §6 heuristics + a running table of every migration's numbers

Growth contract — the last task of every migration:

  • Converted a component? → add its before/after to component-cookbook.md.
  • Hit a surprise? → one entry in gotchas.md.
  • Found a test pattern? → test-recipes.md.
  • Playbook step missing/wrong? → fix SKILL.md.
  • Measured the scorecard? → append the row to scorecard.md.

Success signal: each migration should reuse more cookbook entries and start from a better scorecard baseline than the previous one. If migration N+1 is not easier than N, the skill-update step is being skipped — treat that as a bug.


6. Quality scorecard (the ratchet)

Cheap to measure (grep -c, wc -l, clj-kondo), recorded before/after each migration in the commit message and scorecard.md. No metric may regress for the touched modal.

# Heuristic Measure Target
1 Faked cursor positions (not cursors themselves) count cursor-root rebinds (binding of *current*/*prefix*/*form-data*, or with-field/with-* used to re-root deeper) + grep -c '\-no-cursor' → 0 (top-rooted cursors are fine)
2 Implicit state merges (snapshot/cursor) count merge sites → 0 (forms); explicit update-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

These are directional evidence, not targets to game. Pair them with the e2e parity gate (§7) so "simpler" can never mean "broken."


7. Testing strategy

Consistent with the project's testing-conventions skill (test user-observable behavior; assert DB state directly; don't test the means).

  1. Characterization e2e first. Before changing a modal, write/confirm a Playwright spec capturing its current behavior — focus/caret survival across swaps, the field round-trip, validation errors, and the actual save. This spec is the parity contract the refactor must keep green.
  2. Pure-function checks via REPL. Once render fns are pure, exercise the data-prep functions with clojure-eval / clj-nrepl-eval. Assert on returned data; for markup use string matches ((re-find #"accounts\[0\]\[account\]" (str html))) — this style survives the Selmer switch. Avoid brittle structural assertions.
  3. DB-state assertions for mutations. If a submit writes Datomic, verify by querying the DB, not by asserting on markup.

Regression gate: the full e2e suite must stay green after every migration. Record the current pass/fail baseline in test-recipes.md at the first migration and never drop below it.


8. Per-migration playbook (the repeatable loop)

This is the canonical loop each modal phase follows; it lives in SKILL.md. Modal phases below list only what is specific to that modal plus this loop.

  1. Read the skill. Note applicable cookbook entries and gotchas.
  2. Classify. Single-step → plain form (no server state). Multi-step → wizard (engine + server state). When in doubt, it's a form.
  3. Baseline the scorecard (§6). Record before-numbers.
  4. Characterize behavior (test-first). Write/confirm the e2e spec.
  5. Consolidate render functions so they take explicit data or a top-rooted cursor — remove faked cursor positions and *-no-cursor* duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
  6. Templatize in Selmer; reuse cookbook bits, add new ones back (heuristics 5, 8).
  7. Wire HTMX per the swap doctrine (§3.1); focus invariant intact; OOB only for disjoint regions (heuristic 7).
  8. Collapse routes to 2 (+1 for add-row) (heuristic 6).
  9. Verify: modal e2e + full suite green; assert DB mutations; REPL-check pure fns. Re-measure scorecard — no regressions.
  10. Commit one reversible feature commit; message includes the scorecard delta and reused/new cookbook entries.
  11. Feed the skill (cookbook / gotchas / test-recipes / scorecard / SKILL.md). Not optional.

9. Phases & tasks

Migration target inventory (verify line counts at execution time):

Modal File Steps Target Phase
Transaction Edit transaction/edit.clj 1 (mode toggle) form 2 (skill trial)
Transaction Bulk Code transaction/bulk_code.clj 1 form 3
Sales Summary Edit pos/sales_summaries.clj 1 form 4
Invoice Bulk Edit invoices.clj 1 form 5
Transaction Rule admin/transaction_rules.clj 2 wizard 6
Invoice Pay invoices.clj 2 wizard 7
New Invoice invoice/new_invoice_wizard.clj 3 wizard 8
Vendor admin/vendors.clj 5 wizard 9
Client admin/clients.clj 7 wizard 10

Phase 1 — Distil the skill (no app code changes)

Rationale: the transaction-edit modal has already been migrated to the whole-form swap approach successfully. Capture that working method as a skill now, so every later migration is cheaper and consistent. (If the reference implementation is not yet on the working branch, merge it first — that is an acceptable prerequisite.)

  • Create .claude/skills/ssr-form-migration/SKILL.md with the playbook (§8).
  • Write reference/swap-doctrine.md from §3.1 (the four rules, focus invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit swaps as worked examples.
  • Write reference/render-functions.md from §3.2 (explicit data or a top-rooted cursor; remove faked positions and *-no-cursor* duplicates).
  • Write reference/form-vs-wizard.md from §3.3 (classification + engine).
  • Stub reference/selmer-conventions.md from §3.4, marked "validated in Phase 2."
  • Seed component-cookbook.md with whatever transaction-edit already proved (e.g. the hardened typeahead, the totals-in-sibling-<tbody> pattern).
  • Seed gotchas.md (stale $refs, key-by-value).
  • Seed test-recipes.md; record the current full e2e pass/fail baseline.
  • Create scorecard.md with the §6 table and an empty results table.
  • Exit criteria: an agent can read SKILL.md and the references and understand the whole method without this plan.

Phase 2 — Trial the skill on Transaction Edit (first test subject)

Rationale: validate the freshly written skill against the one modal whose "correct" outcome we already know. This is also where Selmer + pure functions are completed for this modal and the Selmer conventions get written from a real, verified example. Target type: plain form (single step with a mode toggle — the toggle is just a GET with a ?mode= query param that re-renders the form).

Foundation (do once, here):

  • Add the selmer dependency to project.clj.
  • Build the render helper (selmer/render-file) and the interop bridge (Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup).
  • Prove interop: a throwaway Selmer page renders inside the existing layout, and a Hiccup component renders inside a Selmer template.

Modal migration (run the §8 loop), specifics:

  • Confirm/author the characterization e2e spec covering: typing in memo keeps focus; selecting an account updates only its Location options; changing vendor / adding / removing a row / toggling mode / toggling $-vs-% re-renders the whole form correctly; amount edits update totals without losing the amount caret; save round-trips.
  • Extract pure render fns: render-simple-fields, render-advanced-fields, account-row, account-totals (remove any *-no-cursor* duplicates).
  • Convert those render fns to Selmer templates; record each as a cookbook entry; finalize selmer-conventions.md.
  • Verify the swaps match the doctrine (whole-form for structural changes, targeted cell for account→location, sibling-<tbody> for totals, no request for memo); confirm grep -c hx-swap-oob is 0.
  • Collapse routes: GET /transaction/edit (with ?mode=), POST /transaction/edit, plus the single edit-form-changed re-render endpoint.
  • Verify (modal e2e + full suite green; DB save asserted).
  • Feed the skill: refine SKILL.md and references from anything the trial revealed; append the scorecard row (this is the baseline others beat).
  • Exit criteria: skill-driven migration reproduces the known-good behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries.

Phase 3 — Transaction Bulk Code (plain form)

Rationale: the smallest fresh modal — first real test of "read the skill, apply it cold." Single-step form currently wearing a wizard costume.

  • Run the §8 loop.
  • Classify as plain form; delete the wizard protocol/record and snapshot.
  • Extract render-bulk-code-fields; reuse cookbook typeahead/money-input.
  • Search params preserved as plain hidden fields (no EDN snapshot).
  • Collapse 4 wizard routes → 2 (GET open, POST submit).
  • Verify bulk-code applies correctly (assert DB) + full suite green.
  • Feed the skill; append scorecard row.
  • Exit criteria: ≥2 cookbook entries reused; LOC, route-count, and faked-cursor count all down vs. baseline.

Phase 4 — Sales Summary Edit (plain form)

Rationale: another single-step form; reinforces the cold-apply loop.

  • Run the §8 loop.
  • Classify as plain form; remove wizard record + wrap-init-multi-form-state.
  • Extract render-sales-summary-fields (pure); reuse cookbook entries.
  • Collapse 3 wizard routes → 2.
  • Verify edit saves (assert DB) + full suite green.
  • Feed the skill; append scorecard row.

Phase 5 — Invoice Bulk Edit (plain form with rows + totals)

Rationale: first single-step form with dynamic account rows and live totals — exercises the add-row endpoint and the totals-in-sibling-<tbody> swap (instead of OOB).

  • Run the §8 loop.
  • Extract bulk-edit-account-row (pure); reuse the account-row/totals cookbook entries from Phase 2.
  • Add-row: a POST that appends a fresh row; totals re-render via the sibling-<tbody> swap, not OOB.
  • Collapse 4 wizard routes → 3 (open, submit, add-row).
  • Verify add/remove rows + totals + apply (assert DB) + full suite green.
  • Feed the skill; append scorecard row.
  • Exit criteria: grep -c hx-swap-oob is 0; row/totals patterns are confirmed reusable across two modals now.

Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)

Rationale: the first genuinely multi-step modal, and the simplest one — the right place to introduce the data-driven engine (§3.3) and server-side state.

Engine (do once, here):

  • Create components/wizard_state.clj (atom store, create-wizard!, update-step!, get-all, destroy!, TTL sweep for abandoned wizards). Test the lifecycle via REPL.
  • Create components/wizard2.clj (render-wizard, handle-step-submit, open-wizard). Test render + step navigation.
  • Document the engine usage in reference/form-vs-wizard.md.

Modal migration (run the §8 loop), specifics:

  • Extract render-edit-step and render-test-step (the test step shows a results table); keep validate-transaction-rule as the step :schema/custom check.
  • Define transaction-rule-wizard-config with both steps + :done-fn.
  • Collapse routes → 2 (open, submit).
  • Verify create / edit / run-test (assert DB) + full suite green.
  • Feed the skill; append scorecard row.
  • Exit criteria: engine proven on a real 2-step flow; state TTL works.

Phase 7 — Invoice Pay (2-step wizard)

Rationale: 2 steps with conditional rendering by payment method (e.g., handwrite-check fields) — exercises the engine's :next/conditional branching.

  • Run the §8 loop.
  • Extract render-choose-method-step and render-payment-details-step.
  • Build pay-wizard-config; move setup logic into :init-fn (e.g. the invoice-by-id lookup); branch :next on payment method.
  • Collapse routes → 2.
  • Verify each payment method path (assert DB) + full suite green.
  • Feed the skill; append scorecard row.

Phase 8 — New Invoice (3-step wizard)

Rationale: a true 3-step wizard with a conditional accounts step — the reference multi-step shape.

  • Run the §8 loop.
  • Extract render-basic-details-step, render-accounts-step, render-submit-step; reuse the expense-account row cookbook entry.
  • Define step schemas separately; :next from basic-details skips accounts when not customizing.
  • :init-fn sets defaults (e.g. date = now).
  • Add-row for expense accounts via the sibling-<tbody> totals pattern.
  • Collapse routes → 2 (+1 add-row).
  • Verify create with/without custom accounts (assert DB) + full suite green.
  • Feed the skill; append scorecard row.

Phase 9 — Vendor (5-step wizard)

Rationale: larger multi-step; by now the engine and cookbook are mature.

  • Run the §8 loop.
  • Extract the 5 step render fns: render-info-step, render-terms-step, render-account-step, render-address-step, render-legal-step.
  • Build vendor-wizard-config; handle :new vs :edit via :init-fn (empty vs. loaded entity).
  • Replace the conditional hx-post/hx-put logic with the engine's submit.
  • Collapse routes → 2.
  • Verify create + edit across all steps (assert DB) + full suite green.
  • Feed the skill; append scorecard row.

Phase 10 — Client (7-step wizard) — largest, last

Rationale: the biggest, most complex modal (nested bank accounts, location matches, emails, contact methods). Deliberately last, when the skill is richest.

  • Run the §8 loop; split extraction into sub-tasks per step.
  • Extract the 7 step render fns (:info, :matches, :contact, :bank-accounts, :integrations, :cash-flow, :other-settings).
  • Convert each add-new-entity-handler (bank accounts, location matches, emails, contact methods) to an add-row POST using the cookbook row pattern; drop fc/with-field-default nesting.
  • Build client-wizard-config; :new vs :edit via :init-fn.
  • Collapse routes → 2 (+ add-row endpoints as needed).
  • Verify create + edit across all 7 steps thoroughly (assert DB) + full suite green.
  • Feed the skill; append scorecard row.

Phase 11 — Cleanup

Rationale: remove the now-dead old machinery.

  • Delete the legacy wizard module (protocols + middleware) once no caller remains; remove any v1→v2 shim.
  • Remove the Alpine morph dependency/extension if unreferenced.
  • Decide (Open decision 3) whether to extend Selmer to the remaining static Hiccup, now that the skill makes it cheap.
  • Promote recurring cookbook entries into shared Selmer partials/components.
  • Final scorecard review: confirm the suite-wide LOC/route/complexity drop.

10. Risks & mitigations

Risk Mitigation
Server restart loses in-flight wizard state Server state only for true multi-step wizards; forms hold none. TTL + sweep; consider a durable store if a wizard is long-lived.
Mixed Hiccup/Selmer interop gets messy Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid.
Selmer loses Hiccup's structural testability Lean on e2e + DB assertions; unit-test the data-prep functions, not markup.
Large files hide behavior (clients.clj, edit.clj) They go last, after the skill is rich; characterization e2e first; split per step.
Alpine components break across swaps Codify hardening (null-guarded tippy?/$refs, key-by-value) as a cookbook entry applied everywhere.
Heuristics get gamed (LOC golfing, fake route counts) Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers.
Skill-update step skipped under pressure Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it.
Quality regresses silently Ratchet rule: no metric may regress for a touched modal without a written exception in gotchas.md.

11. Open decisions

  1. Server state scope — server-side state only for multi-step wizards, none for plain forms? (recommended: yes)
  2. Selmer scope — convert only interactive/attribute-heavy components first (hybrid), or all SSR files (full sweep)? (recommended: hybrid, revisit in Phase 11)
  3. Whole-form vs. targeted granularity defaults — confirm the §3.1 priority order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the project default. (recommended: yes)
  4. First step — start by distilling the skill (Phase 1) with the reference implementation merged as a prerequisite, rather than treating the merge itself as step one. (recommended: yes)