Note in 3.1 that targeted hx-select/hx-target swaps in repeated/nested structures may want a consistent scheme -- semantic markup + data-attributes, or a form-path->selector helper (mirroring cursors) -- instead of hand-minting a unique id per element. Framed as a consideration for advanced cases, with a Phase 5 task to settle the convention into the skill cookbook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
38 KiB
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
- 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.
- 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.) - 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
formtoolsmodel. - 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.
- 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 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:
;; 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:
- 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 --> - 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> - 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> - 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))
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:
<tr data-row="account" data-index="0"> <td data-cell="account"> <select hx-post="/transaction/edit-form-changed" hx-target="[data-row='account'][data-index='0'] [data-cell='location']" hx-select="[data-row='account'][data-index='0'] [data-cell='location']" hx-swap="outerHTML" hx-trigger="changed">…</select> </td> <td data-cell="location">…</td> </tr> - 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.
;; 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) andPOST(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 with session-stored step state.
Inspiration — Django
formtoolsWizardView. 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 viaget_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, itsstoragebackends (SessionStorage), andget_all_cleaned_data()(https://django-formtools.readthedocs.io/en/latest/wizard.html).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) 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
SessionStoragemodel. No snapshot, no custom EDN readers, no merge-into-snapshot:;; Storage backed by the Ring session (replaces the hidden EDN snapshot). ;; Path in session: [:wizards <wizard-id> :step-data <step-key>] (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 bywizard-idinside 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.
{# 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
- 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.
- 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.
- 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.
- Quality must measurably improve. Each migration records a scorecard (§6); no metric may regress for the touched modal.
- 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).
- 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.
- 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. - 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.
- Read the skill. Note applicable cookbook entries and gotchas.
- Classify. Single-step → plain form (no server state). Multi-step → wizard (engine + server state). When in doubt, it's a form.
- Baseline the scorecard (§6). Record before-numbers.
- Characterize behavior (test-first). Write/confirm the e2e spec.
- 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. - Templatize in Selmer; reuse cookbook bits, add new ones back (heuristics 5, 8).
- Wire HTMX per the swap doctrine (§3.1); focus invariant intact; OOB only for disjoint regions (heuristic 7).
- Collapse routes to 2 (+1 for add-row) (heuristic 6).
- Verify: modal e2e + full suite green; assert DB mutations; REPL-check pure fns. Re-measure scorecard — no regressions.
- Commit one reversible feature commit; message includes the scorecard delta and reused/new cookbook entries.
- 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.mdwith the playbook (§8). - Write
reference/swap-doctrine.mdfrom §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.mdfrom §3.2 (explicit data or a top-rooted cursor; remove faked positions and*-no-cursor*duplicates). - Write
reference/form-vs-wizard.mdfrom §3.3 (classification + engine). - Stub
reference/selmer-conventions.mdfrom §3.4, marked "validated in Phase 2." - Seed
component-cookbook.mdwith 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.mdwith the §6 table and an empty results table. - Exit criteria: an agent can read
SKILL.mdand 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
selmerdependency toproject.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); confirmgrep -c hx-swap-oobis 0. - Collapse routes:
GET /transaction/edit(with?mode=),POST /transaction/edit, plus the singleedit-form-changedre-render endpoint. - Verify (modal e2e + full suite green; DB save asserted).
- Feed the skill: refine
SKILL.mdand 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 (
GETopen,POSTsubmit). - 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 theaccount-row/totalscookbook entries from Phase 2. - Add-row: a
POSTthat appends a fresh row; totals re-render via the sibling-<tbody>swap, not OOB. - Settle a target-selector convention for repeated/nested rows (§3.1
"Selector strategy"): semantic data-attributes and/or a
form-path -> selectorhelper, rather than hand-minted ids per element. Record the chosen convention inreference/swap-doctrine.md+component-cookbook.mdso 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-oobis 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.cljbacked 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 bywizard-idinside the session ([:wizards <id> ...]) 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 onlywizard-idrides 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-stepandrender-test-step(the test step shows a results table); keepvalidate-transaction-ruleas the step:schema/custom check. - Define
transaction-rule-wizard-configwith 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-stepandrender-payment-details-step. - Build
pay-wizard-config; move setup logic into:init-fn(e.g. theinvoice-by-idlookup); branch:nexton 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;
:nextfrom basic-details skips accounts when not customizing. :init-fnsets 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:newvs:editvia:init-fn(empty vs. loaded entity). - Replace the conditional
hx-post/hx-putlogic 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-rowPOSTusing the cookbook row pattern; dropfc/with-field-defaultnesting. - Build
client-wizard-config;:newvs:editvia: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
- Wizard state storage — store multi-step state in the Ring session
(Django
formtoolsSessionStoragemodel), keyed bywizard-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) - Selmer scope — convert only interactive/attribute-heavy components first (hybrid), or all SSR files (full sweep)? (recommended: hybrid, revisit in Phase 11)
- 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)
- 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)