diff --git a/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md b/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md
new file mode 100644
index 00000000..2e0648a5
--- /dev/null
+++ b/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md
@@ -0,0 +1,777 @@
+# 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, and
+ **store each step's data in the session** (combined only at the end) instead
+ of round-tripping and merging an EDN snapshot — the Django `formtools` model.
+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.
+
+```clojure
+;; 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
+10–20 routes with stacked middleware — all for a single-step form. That is pure
+overhead.
+
+### 2.4 Multi-step wizards round-trip and merge a snapshot
+The genuine multi-step wizards carry the whole accumulating form state as an EDN
+snapshot in hidden fields, then rebuild it each request by merging the posted
+pieces back into the snapshot. The serialization needs custom readers, the merge
+logic is error-prone, and the page payload grows with every step. The fix is to
+**store each step's data in the session under its own key and combine only at the
+end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
+
+### 2.5 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:
+
+```clojure
+;; 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:
+
+```html
+
+```
+
+---
+
+## 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.
+ ```html
+
+
+ ```
+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.
+ ```html
+
+
+
...location options...
+ ```
+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**.
+ ```html
+
+ ```
+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 `` so an amount edit can swap totals without replacing the
+ amount input:
+ ```clojure
+ ;; 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:
+```clojure
+;; 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))
+```
+
+**Selector strategy for targeted swaps (a consideration, not a mandate).**
+Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach
+— a unique `id` on every swappable element — gets noisy in repeated structures
+(e.g. a table of financial accounts where choosing an account must swap *that
+row's* dropdown). When you reach those advanced cases, consider a more
+consistent scheme instead of hand-minting ids everywhere:
+
+- **Semantic markup + data-attributes** to craft a fine-grained selector without
+ per-element ids. For example, mark rows/cells with their identity and target
+ by attribute:
+ ```html
+
+
+
+
+
…
+
+ ```
+- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
+ cursor path is, so the server and the markup agree on the target by
+ construction rather than by convention. A render fn at form-path
+ `[:accounts 0 :location]` would compute its own stable selector (id or
+ data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
+
+The aim is *consistency and predictability* of swap targets in repeated/nested
+structures — pick whichever keeps targets unambiguous and easy to generate. Note
+this in `reference/swap-doctrine.md` and let the first modal that hits nested
+repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
+
+### 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.
+
+```clojure
+;; 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}))
+ ...))
+```
+
+```clojure
+;; 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.
+ ```clojure
+ {::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 with session-stored step state.**
+
+ > **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
+ > round-trip a serialized blob of the whole form through the page. Each step's
+ > validated (cleaned) data is written to a **storage backend (the user session
+ > by default)** under that step's key, and the steps are combined only at the
+ > very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
+ > EDN snapshot + piecewise merging with per-step form state stored in the
+ > session.** A step writes its own data under its own key; nothing is merged
+ > into a snapshot and nothing about other steps rides through the form.
+ > Refs: `formtools.wizard.views.WizardView`, its `storage` backends
+ > (`SessionStorage`), and `get_all_cleaned_data()`
+ > (https://django-formtools.readthedocs.io/en/latest/wizard.html).
+
+ A wizard is *data*:
+ ```clojure
+ (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) whose state lives **in the session**, keyed
+ by a wizard instance id, with each step's data stored under its own step key —
+ the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
+ merge-into-snapshot:
+ ```clojure
+ ;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
+ ;; Path in session: [:wizards :step-data ]
+ (defn create-wizard! [session config]
+ (let [id (str (java.util.UUID/randomUUID))]
+ [id (assoc-in session [:wizards id]
+ {:current-step (-> config :steps first :key) :step-data {}})]))
+
+ (defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
+ (defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
+ (defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
+ (defn forget [session id] (update session :wizards dissoc id))
+
+ (defn render-wizard [{:keys [wizard-id config session request]}]
+ (let [{:keys [current-step step-data]} (get-in session [:wizards 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"}
+ ;; only a reference token rides in the form -- not the form's state
+ (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 {})))]))
+
+ ;; Handlers thread the (possibly updated) session back into the Ring response.
+ (defn handle-step-submit [config {:keys [session] :as 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 :session session
+ :request (assoc request :errors errors)})
+ html-response)
+ (let [session' (put-step session wizard-id (keyword current-step) data)
+ nxt ((:next step) data)]
+ (if (= nxt :done)
+ (-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
+ (assoc :session (forget session' wizard-id)))
+ (let [session'' (set-step session' wizard-id nxt)]
+ (-> (html-response (render-wizard {:wizard-id wizard-id :config config
+ :session session'' :request request}))
+ (assoc :session session''))))))))
+ ```
+ Two routes per wizard: open (`partial open-wizard config`) and submit
+ (`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
+ the session, so multiple in-flight wizards (and tabs) don't collide, and it is
+ discarded on completion (`forget`). See Open decision 1 for the storage-backend
+ choice (Ring session store vs. a durable store for long-lived wizards).
+
+### 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.
+
+```html
+{# templates/components/typeahead.html #}
+
+```
+
+```clojure
+;; 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,
+ # target-selector strategy (semantic/data-attr/form-path->id)
+ 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-`` 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-`` 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-`` 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-`` swap, **not** OOB.
+- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
+ "Selector strategy"): semantic data-attributes and/or a `form-path -> selector`
+ helper, rather than hand-minted ids per element. Record the chosen convention
+ in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it.
+- [ ] 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 **session-stored
+per-step state** (the Django `formtools` model), replacing the EDN snapshot +
+merge.
+
+**Engine (do once, here):**
+- [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
+ `create-wizard!`, `put-step` (replace step data, do **not** merge into a
+ snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
+ namespaced by `wizard-id` inside the session (`[:wizards ...]`) so tabs
+ and concurrent wizards don't collide. Each fn returns the updated session for
+ the handler to thread into the Ring response. Test the lifecycle via REPL.
+- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
+ `open-wizard`) — engine threads session through and only `wizard-id` rides in
+ the form. Test render + step navigation + that no snapshot is emitted.
+- [ ] Document the engine usage and the formtools inspiration 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-`` 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 |
+|------|------------|
+| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
+| 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. **Wizard state storage** — store multi-step state in the **Ring session**
+ (Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
+ plain forms? Confirm the session backend in use (in-memory vs. durable) is
+ acceptable for in-flight wizard lifetime, or pick a durable store for
+ long-lived flows. *(recommended: session storage, scoped to multi-step
+ wizards only)*
+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)*