Rewrite the plan to stand on its own: state the goals and target patterns directly (illustrated with code snippets) instead of reconciling experimental workstreams. Spell out every migration as concrete, checkboxed tasks an agent can execute, with per-modal rationale and specifics. Reorder so the first step distils the proven transaction-edit migration into a ssr-form-migration skill (Phase 1), then trials that skill on the same modal as its first test subject (Phase 2), then rolls out simplest-first with every phase feeding the skill. Adds an explicit migration inventory, per-migration playbook, quality scorecard, and test-first strategy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
31 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, instead of mutating the DOM in place. This removes the class of bugs around stale state, lost focus/caret, and out-of-band patching.
- Make render functions pure. A render function takes an explicit data map
and returns markup. No dynamic bindings, no "cursor" context, no duplicate
*-no-cursor*variants. - 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.
- 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 Cursor-based rendering forces duplicate functions
Render code that reads from dynamic bindings (a "form cursor") is context-dependent and hard to test, which has spawned duplicate render functions — one that reads the cursor and one that takes plain params:
;; SMELL: needs cursor context (dynamic bindings *form-data* / *current* / *prefix*)
(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 cursor
(defn account-row-no-cursor* [{:keys [account index client-id]}]
...)
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 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))
3.2 Pure render functions
One function, explicit data in, markup out:
;; 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}))
...))
If a caller still has a cursor, give it a thin wrapper that adapts cursor → data and calls the pure function. Never duplicate 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. 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
- 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
pure-render.md # §3.2 pure functions + thin cursor adapters
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 | Form-cursor / dynamic-binding usage | `grep -cE 'fc/with-field | *form-data* |
| 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.
- Extract pure render functions (kills cursor faking and
*-no-cursor*duplicates — heuristics 1, 2). - 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/pure-render.mdfrom §3.2. - 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, cursor-use 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. - 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 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-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 |
|---|---|
| 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
- Server state scope — server-side state only for multi-step wizards, none for plain forms? (recommended: yes)
- 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)