diff --git a/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md b/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md new file mode 100644 index 00000000..2e0648a5 --- /dev/null +++ b/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md @@ -0,0 +1,777 @@ +# SSR Form & Wizard Simplification — Migration Plan + +> **Status:** Planning / for execution by an agent or engineer. +> **Owner:** Bryce +> **Type:** Refactor (no user-facing behavior change; parity required). + +This plan describes a series of low-risk migrations that make the server-side +rendered (SSR) forms and wizards substantially simpler. It is self-contained: +every concept needed to execute is stated here, illustrated with code snippets. +The work is sequenced so each migration is small, reversible, and *teaches a +skill* that makes the next migration cheaper. + +--- + +## 1. Goals + +1. **Render forms by re-rendering the whole form** (or a precise, isolated + fragment) over HTMX, using hx-select to choose elements, instead of mutating + the DOM in place. This removes the class of bugs around stale state, lost + focus/caret, and out-of-band patching. +2. **Root cursors at the top; never fake their position.** Cursors are fine and + stay — a render function may take an explicit data map *or* a cursor. What we + remove is the practice of **faking a cursor to start deeper** in the tree to + satisfy a partial render, and the duplicate `*-no-cursor*` variants that + fakery forces. The target: a cursor always begins at the top level of what the + form consumes and walks down naturally from there. (Because the whole form is + re-rendered each time, there is no longer any reason to fake a deep starting + position.) +3. **Stop forcing single-step forms through wizard machinery.** Most "wizards" + are single-step; they become plain forms. Genuine multi-step flows use a + small data-driven engine instead of protocols + middleware stacking, and + **store each step's data in the session** (combined only at the end) instead + of round-tripping and merging an EDN snapshot — the Django `formtools` model. +4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the + interactive, attribute-heavy components, so Alpine/HTMX attributes are + first-class HTML rather than a mix of Clojure keywords and strings. +5. **Capture the migration method in a skill** that is created after the first + successful migration and extended by every migration thereafter. + +Net effect target: large reduction in lines of code, route count, and branching +complexity, with measurably more reuse across similar forms. + +--- + +## 2. Why — the current pain (rationale) + +### 2.1 In-place DOM mutation is fragile +Re-rendering only fragments and patching the rest (via morph or out-of-band +swaps) means the server and the DOM can disagree. Keeping a focused input alive +through a patch requires keying tricks and guards. Re-rendering the **whole +form** and letting the typed value ride along in the form is simpler and +correct, *provided the input the user is typing in is never inside the region +being swapped*. + +### 2.2 Faking cursor positions forces duplicate functions +A "form cursor" itself is fine. The pain comes from **faking the cursor's +starting position** — rebinding the dynamic root deeper in the tree so a deeply +nested render function can run against a fragment. That fakery is fragile and +hard to follow, and it has spawned duplicate render functions: one that reads the +faked cursor and one that takes plain params for the cases where the fake can't +be set up. + +```clojure +;; SMELL: this render fn assumes the cursor was faked to start deep at an account, +;; so it only works when *current*/*prefix* were rebound to point there first. +(defn account-row* [{:keys [value client-id]}] + (com/data-grid-row + (fc/with-field :transaction-account/account + (com/data-grid-cell + (account-typeahead* {:value (fc/field-value) :name (fc/field-name)}))) + ...)) + +;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor +(defn account-row-no-cursor* [{:keys [account index client-id]}] + ...) +``` + +**Target:** the cursor starts at the top of the form's data and walks down +naturally; a row render either takes explicit row data or receives a cursor the +caller advanced step-by-step from the root — never one teleported to a deep node. + +### 2.3 Single-step forms wear wizard costumes +Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods), +serialize an EDN snapshot with custom readers into hidden fields, and register +10–20 routes with stacked middleware — all for a single-step form. That is pure +overhead. + +### 2.4 Multi-step wizards round-trip and merge a snapshot +The genuine multi-step wizards carry the whole accumulating form state as an EDN +snapshot in hidden fields, then rebuild it each request by merging the posted +pieces back into the snapshot. The serialization needs custom readers, the merge +logic is error-prone, and the page payload grows with every step. The fix is to +**store each step's data in the session under its own key and combine only at the +end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged. + +### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous +The same attribute is sometimes a keyword and sometimes a string in the same +file, and event handlers must be strings while structural Alpine attrs are +keywords. There is no rule a reader (or an LLM) can rely on: + +```clojure +;; Both of these appear in one component file today: +:x-ref "input" ; keyword key +"x-ref" "hidden" ; string key +:x-model "value.value" +"x-model" "search" +"@keydown.down.prevent.stop" "tippy.show();" ; handlers must be strings +:x-init "..." ; structural attrs are keywords +``` + +In a Selmer template the same markup is unambiguous plain HTML: + +```html + +``` + +--- + +## 3. Target state (the patterns, with snippets) + +These four patterns are what every migration moves code *toward*. The skill +(§5) holds the canonical, growing version of each. + +### 3.1 Whole-form HTMX swap doctrine + +Decide per interactive control, in this priority order: + +1. **No request** when the field affects nothing else. Its value rides along in + the form and is read on submit. + ```html + + + ``` +2. **Targeted swap of a single isolated cell** when a field's effect is purely + local. Give the cell a stable id and keep it out of the typed input's subtree. + ```html + + +
...location options...
+ ``` +3. **Whole-form swap** when the change touches interdependent state (vendor, + add/remove row, mode toggle, $/% radio). The form's hidden state rides along, + so one swap keeps everything consistent — **no out-of-band swaps**. + ```html +
+ ... +
+ ``` +4. **Out-of-band (OOB) swap only for genuinely disjoint DOM regions** — a global + flash/toast, a nav badge, a modal mounted at the document root. If you are + tempted to OOB something *inside the same feature*, that is a signal to + **restructure the DOM so the dependent element shares a common ancestor** with + the trigger, and use an ordinary swap. Example: put running totals in a + sibling `` so an amount edit can swap totals without replacing the + amount input: + ```clojure + ;; totals live in their own tbody, a sibling of the input rows + (com/data-grid- {:rows ... + :footer-tbody [:tbody {:id "account-totals"} ...]}) + + ;; the amount input swaps ONLY the totals tbody (never itself) + [:input {:name "accounts[0][amount]" + :hx-post "/transaction/edit-form-changed" + :hx-target "#account-totals" :hx-select "#account-totals" + :hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms"}] + ``` + +**Focus invariant (must always hold):** the input the user is typing in is never +inside the region its own request swaps. + +**Alpine components must survive swaps.** Null-guard every reference that depends +on Alpine/tippy being initialised, and key a component by its server-provided +value so a server-driven change re-initialises it instead of preserving stale +state: +```clojure +;; null-guard: +"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..." +;; key by current value so morph/replace re-inits on server change: +(assoc attrs :key (str id "--" current-value)) +``` + +**Selector strategy for targeted swaps (a consideration, not a mandate).** +Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach +— a unique `id` on every swappable element — gets noisy in repeated structures +(e.g. a table of financial accounts where choosing an account must swap *that +row's* dropdown). When you reach those advanced cases, consider a more +consistent scheme instead of hand-minting ids everywhere: + +- **Semantic markup + data-attributes** to craft a fine-grained selector without + per-element ids. For example, mark rows/cells with their identity and target + by attribute: + ```html + + + + + … + + ``` +- **A `form-path -> id` (or `-> selector`) function**, derived the same way a + cursor path is, so the server and the markup agree on the target by + construction rather than by convention. A render fn at form-path + `[:accounts 0 :location]` would compute its own stable selector (id or + data-attribute query) from that path, mirroring §3.2's top-rooted cursor. + +The aim is *consistency and predictability* of swap targets in repeated/nested +structures — pick whichever keeps targets unambiguous and easy to generate. Note +this in `reference/swap-doctrine.md` and let the first modal that hits nested +repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook. + +### 3.2 Render functions: explicit data, or a top-rooted cursor + +One function, data in, markup out. The data can arrive as a plain map or via a +cursor — **as long as the cursor was rooted at the top of the form and walked +down to here**, never faked to start at this depth. + +```clojure +;; GOOD: pure, works everywhere, testable without setup +(defn account-row [{:keys [account index client-id amount-mode]}] + (com/data-grid-row + (com/hidden {:name (str "accounts[" index "][db/id]") + :value (or (:db/id account) "")}) + (com/data-grid-cell + (account-typeahead* {:value (:transaction-account/account account) + :name (str "accounts[" index "][account]") + :client-id client-id})) + ...)) +``` + +```clojure +;; ALSO FINE: a cursor that started at the form root and was advanced naturally. +;; The top-level render walks the cursor; the row fn receives the dereferenced +;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth. +(defn account-rows [accounts-cursor] + (for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked + (account-row {:account @row-cursor :index (fc/index row-cursor) ...}))) +``` + +The rule is about *where the cursor starts*, not whether you use one. If a caller +already holds a top-rooted cursor, advance it and hand the row data (or the +advanced cursor) to one render function. Never rebind the cursor to teleport to a +deep node, and never keep a second `*-no-cursor*` copy of the markup. + +### 3.3 Forms vs. wizards (and the data-driven wizard engine) + +- **Single-step → plain form.** Two routes: `GET` (render) and `POST` (validate + + save). State is plain form fields + an entity id. No snapshot, no server + state, no protocol. + ```clojure + {::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)}))) + ::route/edit-submit (fn [req] (validate-and-save req))} + ``` + +- **Genuinely multi-step → data-driven engine with session-stored step state.** + + > **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not* + > round-trip a serialized blob of the whole form through the page. Each step's + > validated (cleaned) data is written to a **storage backend (the user session + > by default)** under that step's key, and the steps are combined only at the + > very end via `get_all_cleaned_data()`. We adopt the same model: **replace the + > EDN snapshot + piecewise merging with per-step form state stored in the + > session.** A step writes its own data under its own key; nothing is merged + > into a snapshot and nothing about other steps rides through the form. + > Refs: `formtools.wizard.views.WizardView`, its `storage` backends + > (`SessionStorage`), and `get_all_cleaned_data()` + > (https://django-formtools.readthedocs.io/en/latest/wizard.html). + + A wizard is *data*: + ```clojure + (def vendor-wizard-config + {:steps [{:key :info :schema info-schema :fields [...] :render render-info-step + :next (fn [data] :terms)} + {:key :terms :schema terms-schema :fields [...] :render render-terms-step + :next (fn [data] :done)}] + :init-fn (fn [req] {...}) + :submit-route "/admin/vendor/wizard/submit" + :done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))}) + ``` + with a tiny engine (no protocols) whose state lives **in the session**, keyed + by a wizard instance id, with each step's data stored under its own step key — + the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no + merge-into-snapshot: + ```clojure + ;; Storage backed by the Ring session (replaces the hidden EDN snapshot). + ;; Path in session: [:wizards :step-data ] + (defn create-wizard! [session config] + (let [id (str (java.util.UUID/randomUUID))] + [id (assoc-in session [:wizards id] + {:current-step (-> config :steps first :key) :step-data {}})])) + + (defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge + (defn set-step [session id k] (assoc-in session [:wizards id :current-step] k)) + (defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge))) + (defn forget [session id] (update session :wizards dissoc id)) + + (defn render-wizard [{:keys [wizard-id config session request]}] + (let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id]) + step (first (filter #(= (:key %) current-step) (:steps config)))] + [:form#wizard-form {:hx-post (:submit-route config) + :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"} + ;; only a reference token rides in the form -- not the form's state + (com/hidden {:name "wizard-id" :value wizard-id}) + (com/hidden {:name "current-step" :value (name current-step)}) + ((:render step) (assoc request :step-data (get step-data current-step {})))])) + + ;; Handlers thread the (possibly updated) session back into the Ring response. + (defn handle-step-submit [config {:keys [session] :as request}] + (let [{:strs [wizard-id current-step]} (:form-params request) + step (first (filter #(= (:key %) (keyword current-step)) (:steps config))) + data (select-keys (:form-params request) (map name (:fields step)))] + (if-let [errors (mc/explain (:schema step) data)] + (-> (render-wizard {:wizard-id wizard-id :config config :session session + :request (assoc request :errors errors)}) + html-response) + (let [session' (put-step session wizard-id (keyword current-step) data) + nxt ((:next step) data)] + (if (= nxt :done) + (-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end + (assoc :session (forget session' wizard-id))) + (let [session'' (set-step session' wizard-id nxt)] + (-> (html-response (render-wizard {:wizard-id wizard-id :config config + :session session'' :request request})) + (assoc :session session'')))))))) + ``` + Two routes per wizard: open (`partial open-wizard config`) and submit + (`partial handle-step-submit config`). State is namespaced by `wizard-id` inside + the session, so multiple in-flight wizards (and tabs) don't collide, and it is + discarded on completion (`forget`). See Open decision 1 for the storage-backend + choice (Ring session store vs. a durable store for long-lived wizards). + +### 3.4 Selmer templates + +Interactive components render from Selmer templates with plain-HTML attributes. +Selmer composes via `{% include %}` and `{% block %}`; an interop bridge lets a +Selmer template embed Hiccup output (and vice versa) during the transition. + +```html +{# templates/components/typeahead.html #} +
+ + + + ... +
+``` + +```clojure +;; render helper + interop bridge +(defn render [tpl ctx] (selmer/render-file tpl ctx)) +(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }} +;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))] +``` + +--- + +## 4. Principles + +1. **Strangler, not big-bang.** New engine, Selmer renderer, and the swap + doctrine live alongside the old code. Migrate one modal at a time behind its + own route. Old machinery is deleted only when its last caller is gone. +2. **Simplest first.** Each migration is small and reversible (one commit). + Start with the already-proven modal, then the smallest fresh ones, and leave + the largest/most complex for last — by which point the skill is mature. +3. **Skill-driven and self-reinforcing.** After the first successful migration, + distil the method into a skill (§5). Every subsequent migration *reads* the + skill first and *extends* it last. +4. **Quality must measurably improve.** Each migration records a scorecard (§6); + no metric may regress for the touched modal. +5. **Behavior parity is proven by tests, not by reading** (§7). The full e2e + suite must stay green after every migration. + +--- + +## 5. The skill: `ssr-form-migration` + +**When it is created:** in **Phase 1**, immediately after — and distilled from — +the first successful modal migration (the transaction-edit modal, whose +whole-form swap implementation already exists and serves as the reference). The +skill is *not* written speculatively; it encodes a method that already worked. + +**Where:** `.claude/skills/ssr-form-migration/` (matches the existing project +convention, e.g. `.claude/skills/testing-conventions/SKILL.md`). + +**Structure:** +``` +.claude/skills/ssr-form-migration/ + SKILL.md # the playbook (§8): classify → migrate → verify → record + reference/ + swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening, + # target-selector strategy (semantic/data-attr/form-path->id) + render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions + form-vs-wizard.md # §3.3 classification + the data-driven engine + selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns + component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle… + gotchas.md # GROWS: stale $refs, key-by-value, wizard-id GC, coercion… + test-recipes.md # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id + scorecard.md # the §6 heuristics + a running table of every migration's numbers +``` + +**Growth contract — the last task of every migration:** +- Converted a component? → add its before/after to `component-cookbook.md`. +- Hit a surprise? → one entry in `gotchas.md`. +- Found a test pattern? → `test-recipes.md`. +- Playbook step missing/wrong? → fix `SKILL.md`. +- Measured the scorecard? → append the row to `scorecard.md`. + +**Success signal:** each migration should reuse more cookbook entries and start +from a better scorecard baseline than the previous one. If migration N+1 is not +easier than N, the skill-update step is being skipped — treat that as a bug. + +--- + +## 6. Quality scorecard (the ratchet) + +Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded before/after each +migration in the commit message and `scorecard.md`. **No metric may regress for +the touched modal.** + +| # | Heuristic | Measure | Target | +|---|-----------|---------|--------| +| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) | +| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) | +| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ | +| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ | +| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ | +| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) | +| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented | +| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 | + +These are directional evidence, not targets to game. Pair them with the e2e +parity gate (§7) so "simpler" can never mean "broken." + +--- + +## 7. Testing strategy + +Consistent with the project's `testing-conventions` skill (test user-observable +behavior; assert DB state directly; don't test the means). + +1. **Characterization e2e first.** Before changing a modal, write/confirm a + Playwright spec capturing its current behavior — focus/caret survival across + swaps, the field round-trip, validation errors, and the actual save. This + spec is the parity contract the refactor must keep green. +2. **Pure-function checks via REPL.** Once render fns are pure, exercise the + data-prep functions with `clojure-eval` / `clj-nrepl-eval`. Assert on returned + data; for markup use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`) + — this style survives the Selmer switch. Avoid brittle structural assertions. +3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by + querying the DB, not by asserting on markup. + +**Regression gate:** the full e2e suite must stay green after every migration. +Record the current pass/fail baseline in `test-recipes.md` at the first +migration and never drop below it. + +--- + +## 8. Per-migration playbook (the repeatable loop) + +This is the canonical loop each modal phase follows; it lives in `SKILL.md`. +Modal phases below list only what is *specific* to that modal plus this loop. + +1. [ ] **Read the skill.** Note applicable cookbook entries and gotchas. +2. [ ] **Classify.** Single-step → plain form (no server state). Multi-step → + wizard (engine + server state). When in doubt, it's a form. +3. [ ] **Baseline the scorecard (§6).** Record before-numbers. +4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec. +5. [ ] **Consolidate render functions** so they take explicit data or a + top-rooted cursor — remove faked cursor positions and `*-no-cursor*` + duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not. +6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back + (heuristics 5, 8). +7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB + only for disjoint regions (heuristic 7). +8. [ ] **Collapse routes** to 2 (+1 for add-row) (heuristic 6). +9. [ ] **Verify:** modal e2e + full suite green; assert DB mutations; REPL-check + pure fns. Re-measure scorecard — no regressions. +10. [ ] **Commit** one reversible feature commit; message includes the scorecard + delta and reused/new cookbook entries. +11. [ ] **Feed the skill** (cookbook / gotchas / test-recipes / scorecard / + SKILL.md). *Not optional.* + +--- + +## 9. Phases & tasks + +> Migration target inventory (verify line counts at execution time): + +| Modal | File | Steps | Target | Phase | +|-------|------|-------|--------|-------| +| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | form | 2 (skill trial) | +| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | form | 3 | +| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | form | 4 | +| Invoice Bulk Edit | `invoices.clj` | 1 | form | 5 | +| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | 6 | +| Invoice Pay | `invoices.clj` | 2 | wizard | 7 | +| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | 8 | +| Vendor | `admin/vendors.clj` | 5 | wizard | 9 | +| Client | `admin/clients.clj` | 7 | wizard | 10 | + +--- + +### Phase 1 — Distil the skill (no app code changes) + +**Rationale:** the transaction-edit modal has already been migrated to the +whole-form swap approach successfully. Capture that working method as a skill +*now*, so every later migration is cheaper and consistent. (If the reference +implementation is not yet on the working branch, merge it first — that is an +acceptable prerequisite.) + +- [ ] Create `.claude/skills/ssr-form-migration/SKILL.md` with the playbook (§8). +- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus + invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit + swaps as worked examples. +- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a + top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates). +- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine). +- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in + Phase 2." +- [ ] Seed `component-cookbook.md` with whatever transaction-edit already proved + (e.g. the hardened typeahead, the totals-in-sibling-`` pattern). +- [ ] Seed `gotchas.md` (stale `$refs`, key-by-value). +- [ ] Seed `test-recipes.md`; record the **current full e2e pass/fail baseline**. +- [ ] Create `scorecard.md` with the §6 table and an empty results table. +- [ ] **Exit criteria:** an agent can read `SKILL.md` and the references and + understand the whole method without this plan. + +--- + +### Phase 2 — Trial the skill on Transaction Edit (first test subject) + +**Rationale:** validate the freshly written skill against the one modal whose +"correct" outcome we already know. This is also where Selmer + pure functions +are completed for this modal and the Selmer conventions get written from a real, +verified example. Target type: **plain form** (single step with a mode toggle — +the toggle is just a `GET` with a `?mode=` query param that re-renders the form). + +**Foundation (do once, here):** +- [ ] Add the `selmer` dependency to `project.clj`. +- [ ] Build the render helper (`selmer/render-file`) and the **interop bridge** + (Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup). +- [ ] Prove interop: a throwaway Selmer page renders inside the existing layout, + and a Hiccup component renders inside a Selmer template. + +**Modal migration (run the §8 loop), specifics:** +- [ ] Confirm/author the characterization e2e spec covering: typing in memo keeps + focus; selecting an account updates only its Location options; changing vendor + / adding / removing a row / toggling mode / toggling $-vs-% re-renders the + whole form correctly; amount edits update totals without losing the amount + caret; save round-trips. +- [ ] Extract pure render fns: `render-simple-fields`, `render-advanced-fields`, + `account-row`, `account-totals` (remove any `*-no-cursor*` duplicates). +- [ ] Convert those render fns to Selmer templates; record each as a cookbook + entry; finalize `selmer-conventions.md`. +- [ ] Verify the swaps match the doctrine (whole-form for structural changes, + targeted cell for account→location, sibling-`` for totals, no request + for memo); confirm `grep -c hx-swap-oob` is 0. +- [ ] Collapse routes: `GET /transaction/edit` (with `?mode=`), `POST + /transaction/edit`, plus the single `edit-form-changed` re-render endpoint. +- [ ] Verify (modal e2e + full suite green; DB save asserted). +- [ ] **Feed the skill:** refine `SKILL.md` and references from anything the + trial revealed; append the scorecard row (this is the baseline others beat). +- [ ] **Exit criteria:** skill-driven migration reproduces the known-good + behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries. + +--- + +### Phase 3 — Transaction Bulk Code (plain form) + +**Rationale:** the smallest *fresh* modal — first real test of "read the skill, +apply it cold." Single-step form currently wearing a wizard costume. + +- [ ] Run the §8 loop. +- [ ] Classify as plain form; delete the wizard protocol/record and snapshot. +- [ ] Extract `render-bulk-code-fields`; reuse cookbook typeahead/money-input. +- [ ] Search params preserved as plain hidden fields (no EDN snapshot). +- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit). +- [ ] Verify bulk-code applies correctly (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. +- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and + faked-cursor count all down vs. baseline. + +--- + +### Phase 4 — Sales Summary Edit (plain form) + +**Rationale:** another single-step form; reinforces the cold-apply loop. + +- [ ] Run the §8 loop. +- [ ] Classify as plain form; remove wizard record + `wrap-init-multi-form-state`. +- [ ] Extract `render-sales-summary-fields` (pure); reuse cookbook entries. +- [ ] Collapse 3 wizard routes → 2. +- [ ] Verify edit saves (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 5 — Invoice Bulk Edit (plain form with rows + totals) + +**Rationale:** first single-step form with dynamic account rows and live totals +— exercises the add-row endpoint and the totals-in-sibling-`` swap +(instead of OOB). + +- [ ] Run the §8 loop. +- [ ] Extract `bulk-edit-account-row` (pure); reuse the `account-row`/`totals` + cookbook entries from Phase 2. +- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the + sibling-`` swap, **not** OOB. +- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1 + "Selector strategy"): semantic data-attributes and/or a `form-path -> selector` + helper, rather than hand-minted ids per element. Record the chosen convention + in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it. +- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row). +- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. +- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are + confirmed reusable across two modals now. + +--- + +### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard) + +**Rationale:** the first genuinely multi-step modal, and the simplest one — the +right place to introduce the data-driven engine (§3.3) and **session-stored +per-step state** (the Django `formtools` model), replacing the EDN snapshot + +merge. + +**Engine (do once, here):** +- [ ] Create `components/wizard_state.clj` backed by the **Ring session**: + `create-wizard!`, `put-step` (replace step data, do **not** merge into a + snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is + namespaced by `wizard-id` inside the session (`[:wizards ...]`) so tabs + and concurrent wizards don't collide. Each fn returns the updated session for + the handler to thread into the Ring response. Test the lifecycle via REPL. +- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`, + `open-wizard`) — engine threads session through and only `wizard-id` rides in + the form. Test render + step navigation + that no snapshot is emitted. +- [ ] Document the engine usage and the formtools inspiration in + `reference/form-vs-wizard.md`. + +**Modal migration (run the §8 loop), specifics:** +- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a + results table); keep `validate-transaction-rule` as the step `:schema`/custom check. +- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`. +- [ ] Collapse routes → 2 (open, submit). +- [ ] Verify create / edit / run-test (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. +- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works. + +--- + +### Phase 7 — Invoice Pay (2-step wizard) + +**Rationale:** 2 steps with conditional rendering by payment method (e.g., +handwrite-check fields) — exercises the engine's `:next`/conditional branching. + +- [ ] Run the §8 loop. +- [ ] Extract `render-choose-method-step` and `render-payment-details-step`. +- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the + `invoice-by-id` lookup); branch `:next` on payment method. +- [ ] Collapse routes → 2. +- [ ] Verify each payment method path (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 8 — New Invoice (3-step wizard) + +**Rationale:** a true 3-step wizard with a conditional accounts step — the +reference multi-step shape. + +- [ ] Run the §8 loop. +- [ ] Extract `render-basic-details-step`, `render-accounts-step`, + `render-submit-step`; reuse the expense-account row cookbook entry. +- [ ] Define step schemas separately; `:next` from basic-details skips accounts + when not customizing. +- [ ] `:init-fn` sets defaults (e.g. date = now). +- [ ] Add-row for expense accounts via the sibling-`` totals pattern. +- [ ] Collapse routes → 2 (+1 add-row). +- [ ] Verify create with/without custom accounts (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 9 — Vendor (5-step wizard) + +**Rationale:** larger multi-step; by now the engine and cookbook are mature. + +- [ ] Run the §8 loop. +- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`, + `render-account-step`, `render-address-step`, `render-legal-step`. +- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn` + (empty vs. loaded entity). +- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit. +- [ ] Collapse routes → 2. +- [ ] Verify create + edit across all steps (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 10 — Client (7-step wizard) — largest, last + +**Rationale:** the biggest, most complex modal (nested bank accounts, location +matches, emails, contact methods). Deliberately last, when the skill is richest. + +- [ ] Run the §8 loop; split extraction into sub-tasks per step. +- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`, + `:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`). +- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches, + emails, contact methods) to an add-row `POST` using the cookbook row pattern; + drop `fc/with-field-default` nesting. +- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`. +- [ ] Collapse routes → 2 (+ add-row endpoints as needed). +- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full + suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 11 — Cleanup + +**Rationale:** remove the now-dead old machinery. + +- [ ] Delete the legacy wizard module (protocols + middleware) once no caller + remains; remove any v1→v2 shim. +- [ ] Remove the Alpine morph dependency/extension if unreferenced. +- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static + Hiccup, now that the skill makes it cheap. +- [ ] Promote recurring cookbook entries into shared Selmer partials/components. +- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop. + +--- + +## 10. Risks & mitigations + +| Risk | Mitigation | +|------|------------| +| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. | +| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. | +| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. | +| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. | +| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. | +| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. | +| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. | +| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. | + +--- + +## 11. Open decisions + +1. **Wizard state storage** — store multi-step state in the **Ring session** + (Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for + plain forms? Confirm the session backend in use (in-memory vs. durable) is + acceptable for in-flight wizard lifetime, or pick a durable store for + long-lived flows. *(recommended: session storage, scoped to multi-step + wizards only)* +2. **Selmer scope** — convert only interactive/attribute-heavy components first + (hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in + Phase 11)* +3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority + order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the + project default. *(recommended: yes)* +4. **First step** — start by distilling the skill (Phase 1) with the reference + implementation merged as a prerequisite, rather than treating the merge + itself as step one. *(recommended: yes)*