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>
778 lines
38 KiB
Markdown
778 lines
38 KiB
Markdown
# 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
|
||
<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)*
|