# 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)*