# 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, 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. **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.
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
are single-step; they become plain forms. Genuine multi-step flows use a
small data-driven engine instead of protocols + middleware stacking.
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
interactive, attribute-heavy components, so Alpine/HTMX attributes are
first-class HTML rather than a mix of Clojure keywords and strings.
5. **Capture the migration method in a skill** that is created after the first
successful migration and extended by every migration thereafter.
Net effect target: large reduction in lines of code, route count, and branching
complexity, with measurably more reuse across similar forms.
---
## 2. Why — the current pain (rationale)
### 2.1 In-place DOM mutation is fragile
Re-rendering only fragments and patching the rest (via morph or out-of-band
swaps) means the server and the DOM can disagree. Keeping a focused input alive
through a patch requires keying tricks and guards. Re-rendering the **whole
form** and letting the typed value ride along in the form is simpler and
correct, *provided the input the user is typing in is never inside the region
being swapped*.
### 2.2 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:
```clojure
;; 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:
```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))
```
### 3.2 Pure render functions
One function, explicit data in, markup out:
```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}))
...))
```
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) 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.** 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) and server-side state keyed by a UUID token:
```clojure
;; 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.
```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
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\*|\*current\*|\*prefix\*|-no-cursor'` | → 0 |
| 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. [ ] **Extract pure render functions** (kills cursor faking and `*-no-cursor*`
duplicates — heuristics 1, 2).
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/pure-render.md` from §3.2.
- [ ] 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, 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-`` 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.
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are
confirmed reusable across two modals now.
---
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
right place to introduce the data-driven engine (§3.3) and server-side state.
**Engine (do once, here):**
- [ ] Create `components/wizard_state.clj` (atom store, `create-wizard!`,
`update-step!`, `get-all`, `destroy!`, **TTL sweep** for abandoned wizards).
Test the lifecycle via REPL.
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
`open-wizard`). Test render + step navigation.
- [ ] Document the engine usage in `reference/form-vs-wizard.md`.
**Modal migration (run the §8 loop), specifics:**
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
results table); keep `validate-transaction-rule` as the step `:schema`/custom check.
- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`.
- [ ] Collapse routes → 2 (open, submit).
- [ ] Verify create / edit / run-test (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works.
---
### Phase 7 — Invoice Pay (2-step wizard)
**Rationale:** 2 steps with conditional rendering by payment method (e.g.,
handwrite-check fields) — exercises the engine's `:next`/conditional branching.
- [ ] Run the §8 loop.
- [ ] Extract `render-choose-method-step` and `render-payment-details-step`.
- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the
`invoice-by-id` lookup); branch `:next` on payment method.
- [ ] Collapse routes → 2.
- [ ] Verify each payment method path (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 8 — New Invoice (3-step wizard)
**Rationale:** a true 3-step wizard with a conditional accounts step — the
reference multi-step shape.
- [ ] Run the §8 loop.
- [ ] Extract `render-basic-details-step`, `render-accounts-step`,
`render-submit-step`; reuse the expense-account row cookbook entry.
- [ ] Define step schemas separately; `:next` from basic-details skips accounts
when not customizing.
- [ ] `:init-fn` sets defaults (e.g. date = now).
- [ ] Add-row for expense accounts via the sibling-`` totals pattern.
- [ ] Collapse routes → 2 (+1 add-row).
- [ ] Verify create with/without custom accounts (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 9 — Vendor (5-step wizard)
**Rationale:** larger multi-step; by now the engine and cookbook are mature.
- [ ] Run the §8 loop.
- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`,
`render-account-step`, `render-address-step`, `render-legal-step`.
- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn`
(empty vs. loaded entity).
- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit.
- [ ] Collapse routes → 2.
- [ ] Verify create + edit across all steps (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 10 — Client (7-step wizard) — largest, last
**Rationale:** the biggest, most complex modal (nested bank accounts, location
matches, emails, contact methods). Deliberately last, when the skill is richest.
- [ ] Run the §8 loop; split extraction into sub-tasks per step.
- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`,
`:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`).
- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches,
emails, contact methods) to an add-row `POST` using the cookbook row pattern;
drop `fc/with-field-default` nesting.
- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`.
- [ ] Collapse routes → 2 (+ add-row endpoints as needed).
- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full
suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 11 — Cleanup
**Rationale:** remove the now-dead old machinery.
- [ ] Delete the legacy wizard module (protocols + middleware) once no caller
remains; remove any v1→v2 shim.
- [ ] Remove the Alpine morph dependency/extension if unreferenced.
- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static
Hiccup, now that the skill makes it cheap.
- [ ] Promote recurring cookbook entries into shared Selmer partials/components.
- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop.
---
## 10. Risks & mitigations
| Risk | Mitigation |
|------|------------|
| Server restart loses in-flight wizard state | Server state only for true multi-step wizards; forms hold none. TTL + sweep; consider a durable store if a wizard is long-lived. |
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. |
| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. |
| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. |
| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. |
---
## 11. Open decisions
1. **Server state scope** — server-side state only for multi-step wizards, none
for plain forms? *(recommended: yes)*
2. **Selmer scope** — convert only interactive/attribute-heavy components first
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
Phase 11)*
3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority
order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the
project default. *(recommended: yes)*
4. **First step** — start by distilling the skill (Phase 1) with the reference
implementation merged as a prerequisite, rather than treating the merge
itself as step one. *(recommended: yes)*