Files
integreat/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md
Bryce d360316590 docs: add swap-target selector strategy consideration
Note in 3.1 that targeted hx-select/hx-target swaps in repeated/nested
structures may want a consistent scheme -- semantic markup + data-attributes,
or a form-path->selector helper (mirroring cursors) -- instead of hand-minting
a unique id per element. Framed as a consideration for advanced cases, with a
Phase 5 task to settle the convention into the skill cookbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:17:52 -07:00

778 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
1020 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
<input x-ref="input" x-model="value.value"
@keydown.down.prevent.stop="tippy?.show()" />
```
---
## 3. Target state (the patterns, with snippets)
These four patterns are what every migration moves code *toward*. The skill
(§5) holds the canonical, growing version of each.
### 3.1 Whole-form HTMX swap doctrine
Decide per interactive control, in this priority order:
1. **No request** when the field affects nothing else. Its value rides along in
the form and is read on submit.
```html
<!-- a memo / free-text field that influences nothing -->
<input name="memo" /> <!-- no hx-* at all -->
```
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
<!-- selecting an account only changes the valid Location options -->
<select name="accounts[0][account]"
hx-post="/transaction/edit-form-changed"
hx-target="#account-location-0"
hx-select="#account-location-0"
hx-swap="outerHTML" hx-trigger="changed">
</select>
<div id="account-location-0"> ...location options... </div>
```
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
<form id="wizard-form"
hx-post="/transaction/edit-form-changed"
hx-target="#wizard-form" hx-select="#wizard-form" hx-swap="outerHTML">
...
</form>
```
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 `<tbody>` 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
<tr data-row="account" data-index="0">
<td data-cell="account">
<select hx-post="/transaction/edit-form-changed"
hx-target="[data-row='account'][data-index='0'] [data-cell='location']"
hx-select="[data-row='account'][data-index='0'] [data-cell='location']"
hx-swap="outerHTML" hx-trigger="changed">…</select>
</td>
<td data-cell="location">…</td>
</tr>
```
- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
cursor path is, so the server and the markup agree on the target by
construction rather than by convention. A render fn at form-path
`[:accounts 0 :location]` would compute its own stable selector (id or
data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
The aim is *consistency and predictability* of swap targets in repeated/nested
structures — pick whichever keeps targets unambiguous and easy to generate. Note
this in `reference/swap-doctrine.md` and let the first modal that hits nested
repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
### 3.2 Render functions: explicit data, or a top-rooted cursor
One function, data in, markup out. The data can arrive as a plain map or via a
cursor — **as long as the cursor was rooted at the top of the form and walked
down to here**, never faked to start at this depth.
```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 <wizard-id> :step-data <step-key>]
(defn create-wizard! [session config]
(let [id (str (java.util.UUID/randomUUID))]
[id (assoc-in session [:wizards id]
{:current-step (-> config :steps first :key) :step-data {}})]))
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
(defn forget [session id] (update session :wizards dissoc id))
(defn render-wizard [{:keys [wizard-id config session request]}]
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
step (first (filter #(= (:key %) current-step) (:steps config)))]
[:form#wizard-form {:hx-post (:submit-route config)
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
;; only a reference token rides in the form -- not the form's state
(com/hidden {:name "wizard-id" :value wizard-id})
(com/hidden {:name "current-step" :value (name current-step)})
((:render step) (assoc request :step-data (get step-data current-step {})))]))
;; Handlers thread the (possibly updated) session back into the Ring response.
(defn handle-step-submit [config {:keys [session] :as request}]
(let [{:strs [wizard-id current-step]} (:form-params request)
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
data (select-keys (:form-params request) (map name (:fields step)))]
(if-let [errors (mc/explain (:schema step) data)]
(-> (render-wizard {:wizard-id wizard-id :config config :session session
:request (assoc request :errors errors)})
html-response)
(let [session' (put-step session wizard-id (keyword current-step) data)
nxt ((:next step) data)]
(if (= nxt :done)
(-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
(assoc :session (forget session' wizard-id)))
(let [session'' (set-step session' wizard-id nxt)]
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
:session session'' :request request}))
(assoc :session session''))))))))
```
Two routes per wizard: open (`partial open-wizard config`) and submit
(`partial handle-step-submit config`). State is namespaced 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 #}
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
<a class="{{ classes }}" x-ref="input" tabindex="0"
@keydown.down.prevent.stop="tippy?.show()"
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
<span x-text="value.label"></span>
</a>
...
</div>
```
```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-`<tbody>` 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-`<tbody>` 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-`<tbody>` 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-`<tbody>` swap, **not** OOB.
- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
"Selector strategy"): semantic data-attributes and/or a `form-path -> 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 <id> ...]`) so tabs
and concurrent wizards don't collide. Each fn returns the updated session for
the handler to thread into the Ring response. Test the lifecycle via REPL.
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
`open-wizard`) — engine threads session through and 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-`<tbody>` totals pattern.
- [ ] Collapse routes → 2 (+1 add-row).
- [ ] Verify create with/without custom accounts (assert DB) + full suite green.
- [ ] Feed the skill; append scorecard row.
---
### Phase 9 — Vendor (5-step wizard)
**Rationale:** larger multi-step; by now the engine and cookbook are mature.
- [ ] Run the §8 loop.
- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`,
`render-account-step`, `render-address-step`, `render-legal-step`.
- [ ] Build `vendor-wizard-config`; handle `: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)*