From a8d8a8d111dd0efe0186c581318b4a3f17b09ef4 Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 2 Jun 2026 21:56:12 -0700 Subject: [PATCH] docs: make SSR migration plan self-contained and executable Rewrite the plan to stand on its own: state the goals and target patterns directly (illustrated with code snippets) instead of reconciling experimental workstreams. Spell out every migration as concrete, checkboxed tasks an agent can execute, with per-modal rationale and specifics. Reorder so the first step distils the proven transaction-edit migration into a ssr-form-migration skill (Phase 1), then trials that skill on the same modal as its first test subject (Phase 2), then rolls out simplest-first with every phase feeding the skill. Adds an explicit migration inventory, per-migration playbook, quality scorecard, and test-first strategy. Co-Authored-By: Claude Opus 4.8 --- ...factor-ssr-rendering-modernization-plan.md | 1018 ++++++++++------- 1 file changed, 588 insertions(+), 430 deletions(-) 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 index 241f120c..c0bbd723 100644 --- 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 @@ -1,502 +1,660 @@ -# SSR Rendering Modernization — Rollout Plan +# SSR Form & Wizard Simplification — Migration Plan -> **Status:** Planning / for future reference. Nothing here is committed. -> **Author:** Bryce + Claude, 2026-06-02 -> **Scope:** Three intertwined SSR refactors and a strategy for rolling them out -> incrementally and low-risk, where each migration leaves behind a sharper -> **skill** so the next migration is cheaper. +> **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. Purpose +## 1. Goals -Three separate exercises have each produced evidence for a way to simplify the -SSR layer. This document does **not** re-derive them — it sits on top of them -and answers a single question: +1. **Render forms by re-rendering the whole form** (or a precise, isolated + fragment) over HTMX, instead of mutating the DOM in place. This removes the + class of bugs around stale state, lost focus/caret, and out-of-band patching. +2. **Make render functions pure.** A render function takes an explicit data map + and returns markup. No dynamic bindings, no "cursor" context, no duplicate + `*-no-cursor*` variants. +3. **Stop forcing single-step forms through wizard machinery.** Most "wizards" + are single-step; they become plain forms. Genuine multi-step flows use a + small data-driven engine instead of protocols + middleware stacking. +4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the + interactive, attribute-heavy components, so Alpine/HTMX attributes are + first-class HTML rather than a mix of Clojure keywords and strings. +5. **Capture the migration method in a skill** that is created after the first + successful migration and extended by every migration thereafter. -> How do we land all three with minimal blast radius, in an order where each -> step de-risks the next, and where the learnings compound into tooling instead -> of evaporating? - -The three workstreams: - -| # | Workstream | Source | What it is | -|---|------------|--------|------------| -| A | **HTMX swap doctrine** | `integreat-render-whole-form` worktree | Empirical: how to re-render forms on field edits without losing focus/caret, with the fewest moving parts | -| B | **Wizard architecture** | `integreat-critique-wizard` worktree (`docs/superpowers/plans/2025-01-15-wizard-refactor.md` + `-phase-i-trial.md`) | Architectural: replace the `multi_modal.clj` protocol/middleware/EDN-snapshot system with data-driven config + pure render functions | -| C | **Hiccup → Selmer** | This conversation | Templating: move HTML rendering from Hiccup data to Jinja-style Selmer templates so Alpine/HTMX attributes are first-class HTML, not keyword/string soup | - -These are not independent. They all touch the same render functions, and the -right sequencing makes them reinforce each other (§3, Decision 4). +Net effect target: large reduction in lines of code, route count, and branching +complexity, with measurably more reuse across similar forms. --- -## 2. What each workstream actually established +## 2. Why — the current pain (rationale) -### A. HTMX swap doctrine (`render-whole-form`) +### 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*. -The branch iterated through **four** approaches on the transaction-edit form and -the commit history is the real artifact (read `git log integreat-render-hx-select`): +### 2.2 Cursor-based rendering forces duplicate functions +Render code that reads from dynamic bindings (a "form cursor") is +context-dependent and hard to test, which has spawned duplicate render functions +— one that reads the cursor and one that takes plain params: -1. `alpine-morph` whole-form morph — worked but required `@alpinejs/morph`, plus - `key`/`x-data` re-init tricks and guards against stale post-morph `$refs`. -2. `hx-select` fragment swap **+ OOB** refresh of the snapshot/totals. -3. **Whole-form `hx-select` swap with ZERO out-of-band swaps** — the snapshot - hidden field rides along inside the form, so a whole-form swap keeps - interdependent state (accounts + amount-mode + active tab) consistent with no - OOB at all. -4. **Precise per-trigger granularity** on top of #3: - - **Memo** issues *no request* — it affects nothing else; its value rides - along in the form and merges into the snapshot on save. - - **Account select** swaps *only* that row's location cell - (`#account-location-` / `#simple-account-location`) — selecting an - account only changes valid Location options, a truly local effect. - - **Structural changes** (vendor, add/remove row, mode toggle, $/% radio) - swap the **whole** `#wizard-form` because their state is interdependent and - round-trips through the single form-level snapshot. +```clojure +;; SMELL: needs cursor context (dynamic bindings *form-data* / *current* / *prefix*) +(defn account-row* [{:keys [value client-id]}] + (com/data-grid-row + (fc/with-field :transaction-account/account + (com/data-grid-cell + (account-typeahead* {:value (fc/field-value) :name (fc/field-name)}))) + ...)) -**The invariant that makes it all work:** *the input the user is typing in is -never inside the region it triggers a swap of.* Totals were moved into their own -`` (new `:footer-tbody` param on `data-grid-`) so an -amount edit can swap totals without replacing the amount input. +;; SMELL: a second copy of the same markup, just to avoid the cursor +(defn account-row-no-cursor* [{:keys [account index client-id]}] + ...) +``` -**Secondary learning — Alpine components must survive swaps.** `inputs.clj` had -to be hardened: null-guard every `tippy` / `$refs` access (`tippy?.show()`, -`$refs.input?.__x_tippy?.hide()`) and **key the typeahead by its server value** -(`:key (str id "--" value)`) so a server-driven value change re-initialises -Alpine instead of preserving stale `x-data`. +### 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. -**Status:** near-complete, 27/2 e2e passing (the 2 failures are pre-existing and -unrelated). This is the most landable of the three. +### 2.4 Hiccup makes Alpine/HTMX attributes ambiguous +The same attribute is sometimes a keyword and sometimes a string in the same +file, and event handlers must be strings while structural Alpine attrs are +keywords. There is no rule a reader (or an LLM) can rely on: -### B. Wizard architecture (`critique-wizard`) +```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 +``` -A 14-task plan to retire `multi_modal.clj` (5 protocols, 15+ methods, middleware -stacking, EDN-with-custom-readers serialized into hidden fields). Core moves: +In a Selmer template the same markup is unambiguous plain HTML: -- **Wizards become data:** `{:steps [{:key :schema :fields :render :next}...] - :init-fn :done-fn}`. -- **Render functions become pure:** one function that takes an explicit data - map, killing the `*-no-cursor*` duplicate-variant problem caused by dynamic - bindings (`*form-data*`, `*current*`, `*prefix*`) in form-cursor. -- **State moves server-side**, keyed by a `wizard-id` UUID token, replacing the - EDN snapshot in the form. -- **Reclassification:** 4 of the 9 "wizards" are single-step and shouldn't be - wizards at all — they become plain forms (transaction-edit, bulk-code, - sales-summary edit, invoice bulk-edit). - -Inventory (verify line counts at migration time — `edit.clj` is currently 1584, -`clients.clj` 1913, `multi_modal.clj` 400): - -| Name | File | Steps | Target type | -|------|------|-------|-------------| -| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | -| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | **normal form** | -| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | **normal form** | -| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | **normal form** | -| Invoice Pay | `invoices.clj` | 2 | wizard | -| Invoice Bulk Edit | `invoices.clj` | 1 | **normal form** | -| Vendor | `admin/vendors.clj` | 5 | wizard | -| Client | `admin/clients.clj` | 7 | wizard | -| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | - -> The critique plan's `wizard_trial/` and `bulk_code_trial.clj` spike code on the -> branch is a Phase-I proof, not production code. Treat it as reference. - -### C. Hiccup → Selmer - -**Rationale (your framing):** Clojure keywords collide conceptually with Alpine -attributes, and the codebase is genuinely inconsistent about it. Concrete -evidence in `inputs.clj` on `staging`: - -- The **same** attribute appears keyworded and stringified in one file: - `:x-ref "input"` (line 73) vs `"x-ref" "hidden"` (line 83); - `:x-model` (line 63) vs `"x-model" "search"` (line 105). -- Event handlers must be strings (`"@keydown.down.prevent.stop"`, - `"x-tooltip.on.click"`) while structural Alpine attrs are keywords - (`:x-init`, `:x-data`, `:x-show`). There is no rule a reader (or an LLM) can - rely on. - -67 SSR files use this attribute style. In a Jinja-style Selmer template, -`@click`, `:class`, `x-data`, `hx-post` are all just plain HTML attributes — -no escaping, no keyword/string decision, and the template reads like the HTML -that ships. This is the readability win for both humans and LLMs. - -**Honest cost (must be designed around):** - -- Hiccup is *data* — composable with `for`/`map`, unit-testable as a tree, - trivially manipulated. Selmer templates are files/strings; you lose tree - composition and assert on rendered strings instead. (Note: the critique - plan's tests already assert via `(re-find #"..." (str html))`, so that style - survives the switch.) -- The whole component library (`com/data-grid-row`, `com/typeahead`, …) is - Hiccup-function composition. Selmer composes via `{% include %}` / `{% block %}` - and template inheritance — a different model that needs a deliberate - interop bridge during transition. -- Selmer is **not yet a dependency** (only `hiccup "2.0.0-alpha2"`). Net-new dep. +```html + +``` --- -## 3. Key tensions to resolve before mass migration (DECISIONS) +## 3. Target state (the patterns, with snippets) -These are the cross-workstream conflicts. Each needs an explicit decision; my -recommended default is given but the call is yours. +These four patterns are what every migration moves code *toward*. The skill +(§5) holds the canonical, growing version of each. -### Decision 1 — Server-side state vs. embedded snapshot +### 3.1 Whole-form HTMX swap doctrine -The critique plan (B, Task 1) moves all wizard state server-side behind a -`wizard-id`. But `render-whole-form` (A) demonstrated that the EDN snapshot, -once you swap the **whole form with zero OOB**, "rides along" cleanly and the -messy part was never the snapshot — it was the morph/OOB swapping. So A partly -*dissolves* the problem B's Task 1 solves. +Decide per interactive control, in this priority order: -**Recommended default — split by type, which falls out naturally:** -- **Normal forms** (the 4 reclassifications): no snapshot needed at all — plain - fields + an entity id. Zero server state. Simplest possible. -- **True multi-step wizards** (5): server-side state *is* worth it (drops - EDN-with-custom-readers, shrinks payloads). Accept its costs explicitly: - server restart loses in-flight state, multi-tab needs distinct ids, abandoned - wizards need GC/TTL. Use an atom with a timestamp + sweep, as the spike did. +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"} ...]}) -This means **don't** put `multi_modal`'s snapshot machinery on the critical path -for the single-step conversions — they get simpler by *deletion*, not by -swapping one state mechanism for another. + ;; 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"}] + ``` -### Decision 2 — Swap doctrine is now canon, overriding the critique plan's OOB tasks +**Focus invariant (must always hold):** the input the user is typing in is never +inside the region its own request swaps. -Critique Tasks 5 & 11 advocate HTMX **OOB** for related updates. `render-whole-form` -*evolved past that* to zero-OOB whole-form swaps + precise local swaps. Where -they conflict, **A wins** — it is the later, e2e-verified learning. +**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)) +``` -**Recommended rule (this becomes the skill's swap section):** -1. Field affects nothing else → **no request** (value rides along, merged on save). -2. Field has a *purely local* effect → **targeted swap of just that cell**, - with the cell given a stable id and kept out of the typed input's subtree. -3. Field touches interdependent state → **whole-form `hx-select` swap, zero OOB.** -4. **OOB is for genuinely disjoint DOM regions only.** Bryce's observation from - the branch: nearly every OOB he had to fix wasn't a real "separate region" - case — it was a target that *should have lived higher in the DOM hierarchy*, - where a normal swap of a common ancestor would have covered it. So the rule - is: **before reaching for OOB, try to hoist the target to a shared ancestor** - so one ordinary swap covers both the trigger's neighborhood and the dependent - element. (The totals move into `` is the canonical - example — a sibling of the input rows, swapped normally, *not* OOB.) Use OOB - only when the dependent element is **truly elsewhere in the page** and cannot - reasonably share an ancestor: a global flash/toast, a nav badge/counter, a - modal mounted at the document root. **If you're tempted to OOB inside the same - feature, that's a signal to restructure the DOM, not to add an OOB.** +### 3.2 Pure render functions -### Decision 3 — Selmer scope: full vs. hybrid +One function, explicit data in, markup out: -- **Full:** every SSR file → Selmer. Maximum consistency, maximum risk/effort. -- **Hybrid (recommended):** migrate the **Alpine/HTMX-heavy interactive leaves** - (forms, typeaheads, wizard steps, data-grid rows) to Selmer first, where the - keyword/string pain is concentrated and the LLM-confusion is worst. Leave - static layout/structural Hiccup alone until proven worth it. Capture ~80% of - the readability benefit for ~20% of the churn, and let the migration's own - evidence decide whether to go full later. +```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})) + ...)) +``` -### Decision 4 — Sequence Selmer relative to B +If a caller still has a cursor, give it a *thin* wrapper that adapts cursor → +data and calls the pure function. Never duplicate the markup. -Do **not** run Selmer as a separate sweep over files you're also restructuring -for B. The shared seam is **the pure render function** (B's Task 3): both the -wizard engine and Selmer want pure, data-in render units. So: +### 3.3 Forms vs. wizards (and the data-driven wizard engine) -> When a feature is migrated, do all three at once *for that feature*: -> extract pure render fn (B) → express it as a Selmer template (C) → wire its -> HTMX per the swap doctrine (A). +- **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))} + ``` -One pass per file, three wins. This is also what makes the compounding skill -coherent — every migration exercises the whole playbook. +- **Genuinely multi-step → data-driven engine.** A wizard is *data*: + ```clojure + (def vendor-wizard-config + {:steps [{:key :info :schema info-schema :fields [...] :render render-info-step + :next (fn [data] :terms)} + {:key :terms :schema terms-schema :fields [...] :render render-terms-step + :next (fn [data] :done)}] + :init-fn (fn [req] {...}) + :submit-route "/admin/vendor/wizard/submit" + :done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))}) + ``` + with a tiny engine (no protocols) and server-side state keyed by a UUID token: + ```clojure + ;; state: an atom keyed by wizard-id (add a timestamp + TTL sweep) + (defonce ^:private store (atom {})) + (defn create-wizard! [init] (let [id (str (java.util.UUID/randomUUID))] + (swap! store assoc id {:current-step (-> init :steps first :key) + :step-data {} :created-at (System/currentTimeMillis)}) + id)) + (defn update-step! [id k data] (swap! store update-in [id :step-data k] merge data)) + (defn get-all [id] (apply merge (vals (:step-data (@store id))))) + + (defn render-wizard [{:keys [wizard-id config request]}] + (let [{:keys [current-step step-data]} (@store wizard-id) + step (first (filter #(= (:key %) current-step) (:steps config)))] + [:form#wizard-form {:hx-post (:submit-route config) + :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"} + (com/hidden {:name "wizard-id" :value wizard-id}) + (com/hidden {:name "current-step" :value (name current-step)}) + ((:render step) (assoc request :step-data (get step-data current-step {})))])) + + (defn handle-step-submit [config request] + (let [{:strs [wizard-id current-step]} (:form-params request) + step (first (filter #(= (:key %) (keyword current-step)) (:steps config))) + data (select-keys (:form-params request) (map name (:fields step)))] + (if-let [errors (mc/explain (:schema step) data)] + (render-wizard {:wizard-id wizard-id :config config :request (assoc request :errors errors)}) + (do (update-step! wizard-id (keyword current-step) data) + (let [nxt ((:next step) data)] + (if (= nxt :done) + (let [all (get-all wizard-id)] (swap! store dissoc wizard-id) ((:done-fn config) all request)) + (do (swap! store assoc-in [wizard-id :current-step] nxt) + (render-wizard {:wizard-id wizard-id :config config :request request})))))))) + ``` + Two routes per wizard: open (`partial open-wizard config`) and submit + (`partial handle-step-submit config`). + +### 3.4 Selmer templates + +Interactive components render from Selmer templates with plain-HTML attributes. +Selmer composes via `{% include %}` and `{% block %}`; an interop bridge lets a +Selmer template embed Hiccup output (and vice versa) during the transition. + +```html +{# templates/components/typeahead.html #} +
+ + + + ... +
+``` + +```clojure +;; render helper + interop bridge +(defn render [tpl ctx] (selmer/render-file tpl ctx)) +(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }} +;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))] +``` --- -## 4. Code-quality ratchet (heuristics + per-migration scorecard) +## 4. Principles -The rollout only succeeds if quality monotonically *improves*. To keep that -honest rather than aspirational, every migration measures a small **scorecard** -before and after, and the numbers go in the commit message and a running table -in the skill. The rule is a **ratchet**: a migration may not worsen any metric -for the feature it touches, and shared work it introduces should *lower future* -features' starting numbers. - -These heuristics are deliberately cheap to measure (mostly `grep -c` / `wc -l` / -`clj-kondo`) so the step never gets skipped. - -| # | Heuristic | What it proves | How to measure (per feature) | -|---|-----------|----------------|------------------------------| -| 1 | **Form-cursor / dynamic-binding usage → 0** | Whole-form re-render means we never "fake out" a position with the cursor. Pure functions take explicit data. | `grep -cE 'fc/with-field|\*form-data\*|\*current\*|\*prefix\*|-no-cursor'` in the file. Target: trends to 0. | -| 2 | **Implicit state merges → 0** | State is either plain form params (normal forms) or explicit keyed step-data (wizards) — never cursor/snapshot merge magic. | Count snapshot-merge / cursor-merge call sites. Target: 0 for normal forms; explicit `update-step!` only for wizards. | -| 3 | **Cyclomatic complexity ↓** | Simpler branching in render + handler. | `clj-kondo` complexity, or proxy: count `cond`/`condp`/`case`/nested `if` and max nesting depth per render/handler fn. Target: net decrease. | -| 4 | **Lines of code ↓** | The headline simplification. | `wc -l` on the feature's file(s). Target: meaningful net drop (critique est. ~60% across the suite). | -| 5 | **Reuse / cross-form similarity ↑** | Similar forms look similar and share render units, instead of each reinventing a row/typeahead/totals block. | Count cookbook components reused (§7) and duplicated-block count across sibling forms. Target: reuse up, duplication down. | -| 6 | **Route count → 2 (+1 for add-row)** | The middleware-stacking collapse. | Count routes for the feature. Target: 2 (open/GET + submit/POST), +1 only for an add-row endpoint. | -| 7 | **OOB swap count → ~0** | The swap doctrine (Decision 2): OOB only for genuinely disjoint regions. | `grep -c hx-swap-oob` in the feature. Target: 0 unless a justified disjoint-region case is documented. | -| 8 | **Attribute consistency** | The Selmer payoff: no keyword/string attr soup. | After Selmer conversion, Alpine/HTMX attrs are plain HTML — 0 `:x-`/`"x-"` mixed encodings in the migrated template. | - -**Ratchet enforcement:** if any metric regresses for the touched feature, the -migration isn't done — either fix it or write down *why* it's an accepted -exception in the skill's gotchas. The running scorecard table in the skill lets -you see at a glance whether migration N+1 started from a better baseline than N -did (it should, because of shared components). - -> Caveat: these are directional heuristics, not targets to game. Don't shrink LOC -> by golfing, and don't inline shared components to drop a route count. The point -> is to make "is this actually simpler?" answerable with evidence. +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. Testing strategy +## 5. The skill: `ssr-form-migration` -Testing is woven through every phase, not a final gate. Three levels, mapped to -what each is good at, consistent with the project's `testing-conventions` skill -(test user-observable behavior; assert DB state directly; don't test the means): +**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. -1. **Characterization e2e first.** *Before* touching a feature, write/confirm a - Playwright spec capturing current user-observable behavior — focus/caret - survival across swaps, the field round-trip, validation errors, and the - actual save. `render-whole-form`'s `e2e/transaction-edit-swap.spec.ts` is the - template. This spec is the parity contract the refactor must keep green. -2. **Pure-function unit checks via REPL.** Once render fns are pure (B), test the - *data-prep* functions (what feeds the template) with `clojure-eval` / - `clj-nrepl-eval`. Assert on returned data, and on rendered output only via - the string-match style the critique tests already use - (`(re-find #"accounts\[0\]\[account\]" (str html))`) — this style survives - the Selmer switch since Selmer also yields strings. Do **not** write brittle - structural assertions on markup. -3. **DB-state assertions for mutations.** Per `testing-conventions`: if a submit - writes to Datomic, verify by querying the DB, not by asserting on markup. - -**Regression gate (the ratchet's safety net):** the full e2e suite must stay at -or above its current baseline (**27 passing / 2 known-unrelated failures** from -`render-whole-form`) after every migration. A migration that drops a previously -passing spec is blocked until restored. This baseline is what makes "behavior -parity" checkable rather than asserted. - -**Test ordering mirrors the rollout (simplest first):** the bulk-code pilot -(Phase 2) is where the test recipes get written into the skill -(`reference/test-recipes.md`): how to e2e a swap without flaking on focus, how to -assert a Selmer render, how to fixture a wizard-id. By the time the 7-step client -wizard is reached, the test recipes are mature — so the riskiest migration has -the most testing leverage, not the least. - ---- - -## 6. Guiding principles - -1. **Compound, don't just complete.** Every migration ends by updating the skill - (§7) and recording its scorecard (§4). A migration that didn't teach the skill - something, or that can't show its numbers, isn't done. -2. **Lowest-risk-first ordering.** Start with the smallest, simplest, - lowest-traffic surfaces; spend the learning on the scary ones last. The - ordering is itself a quality strategy: the scorecard heuristics (§4) and test - recipes (§5) mature on cheap features so the expensive ones inherit them. -3. **Strangler, not big-bang.** New engine (`wizard2`), Selmer renderer, and the - swap doctrine live *alongside* `multi_modal.clj`/Hiccup. Migrate one feature - at a time behind its own route. `multi_modal.clj` is deleted only when the - last caller is gone. -4. **Behavior parity is proven by e2e, not by reading.** Every migrated feature - keeps/gains a Playwright spec (the `render-whole-form` `e2e/transaction-edit-swap.spec.ts` - is the template) asserting focus/caret survival and the round-trip. -5. **One reversible commit per feature.** Each migration is independently - revertable; never leave a half-migrated wizard on `master`. - ---- - -## 7. The compounding mechanism — a `ssr-form-migration` skill - -This is the heart of the request. Create one evolving skill that every migration -reads first and updates last. - -**Location:** `.claude/skills/ssr-form-migration/SKILL.md` (matches existing -project-skill convention — see `.claude/skills/testing-conventions/SKILL.md`). - -**Initial structure (seeded from A + B + C):** +**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 (§9): decision tree + step-by-step + SKILL.md # the playbook (§8): classify → migrate → verify → record reference/ - swap-doctrine.md # Decision 2 rules, focus invariant, OOB-vs-hoist - wizard-engine.md # wizard2 config shape, normal-form-vs-wizard test - selmer-conventions.md # attr style, interop bridge, include/block patterns - component-cookbook.md # GROWS each migration: typeahead, account-row, - # data-grid, money-input, mode-toggle… as Selmer - gotchas.md # GROWS: stale $refs, key-by-value, GC of wizard ids… - test-recipes.md # how to e2e a swap; how to assert a Selmer render - scorecard.md # the §4 heuristics + running table of every - # migration's before/after numbers + swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening + pure-render.md # §3.2 pure functions + thin cursor adapters + form-vs-wizard.md # §3.3 classification + the data-driven engine + selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns + component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle… + gotchas.md # GROWS: stale $refs, key-by-value, wizard-id GC, coercion… + test-recipes.md # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id + scorecard.md # the §6 heuristics + a running table of every migration's numbers ``` -**The contract — every migration's last step is "feed the skill":** +**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`. -- Did you convert a component to Selmer (typeahead, a row, a button group)? - → add the before/after to `component-cookbook.md`. The next feature that uses - that component now copies instead of rediscovers. -- Hit a swap/focus/Alpine surprise? → one entry in `gotchas.md`. -- Found a step the playbook didn't cover, or a wrong instruction? → fix `SKILL.md`. -- Measured the scorecard (§4)? → append the row to `scorecard.md` so the trend - across migrations stays visible. - -**Success metric for the mechanism:** each successive migration should touch -fewer *novel* problems, reuse more cookbook entries, and start from a better -scorecard baseline than the last. If migration N+1 isn't faster than N, or its -scorecard isn't trending the right way, the skill-update step is being skipped — -treat that as a process bug. Track this explicitly in each migration's commit -message ("reused: X,Y; new cookbook entries: Z; scorecard: LOC −n, cursor-uses -−m, OOB 0"). - -> Consider `/ce-compound` after each migration to capture the durable learning, -> then distill the reusable parts into the skill. +**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. --- -## 8. Phased rollout +## 6. Quality scorecard (the ratchet) -Ordering rationale: **(1)** land the already-finished mechanics work; **(2)** -build the shared foundation + skill; **(3)** prove the full playbook on the -smallest single-step form; **(4)** walk up the difficulty curve. The progression -is strictly simplest → biggest, so the heuristics (§4) and test recipes (§5) -mature on cheap features and the riskiest migration (the 7-step client wizard) -inherits the most tooling. Week labels are relative effort markers, not -commitments. +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.** -### Phase 0 — Decisions & foundation -- Resolve Decisions 1–4 (§3). Record the answers at the top of the skill. -- Add `selmer` dep. Build the **render helper** + **base/layout templates** + - the **interop bridge** (render Hiccup → string for inclusion in a Selmer - template, and vice versa) so mixed-mode is possible during transition. -- Establish the Alpine/HTMX attribute convention in `selmer-conventions.md`. -- Scaffold the `ssr-form-migration` skill with the A/B/C reference docs. -- **Exit criteria:** a throwaway "hello" Selmer page renders inside the existing - layout, and a Hiccup component renders inside a Selmer template (interop proven). +| # | Heuristic | Measure | Target | +|---|-----------|---------|--------| +| 1 | Form-cursor / dynamic-binding usage | `grep -cE 'fc/with-field|\*form-data\*|\*current\*|\*prefix\*|-no-cursor'` | → 0 | +| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) | +| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ | +| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ | +| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ | +| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) | +| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented | +| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 | -### Phase 1 — Land `render-whole-form` (workstream A, mostly done) -- Merge/rebase `integreat-render-hx-select` to `master` via the gitea-tea flow. -- This ships the swap doctrine on transaction-edit and the hardened typeahead - **in Hiccup** (do not block this on Selmer). -- Extract the doctrine into `reference/swap-doctrine.md`. -- **Why first:** highest value-to-risk; it's verified and self-contained, and it - gives the skill its first concrete, battle-tested section. - -### Phase 2 — Pilot the full playbook on the smallest form: **Bulk Code** -- `transaction/bulk_code.clj` (~420 lines, single step) → normal form, - pure render fns, **Selmer** templates, swap doctrine. No server state needed. -- This is critique's own chosen Phase-I subject — small, contained, low traffic. -- This is the first time A+B+C run together on one feature. Expect the cookbook - to fill up fast (typeahead, account row, money input, submit). -- Also the first time the **scorecard** (§4) and **test recipes** (§5) are - written down — this pilot sets the baseline numbers everything else is judged - against. -- **Exit criteria:** e2e parity (suite still ≥27/2); scorecard shows LOC down, - cursor-uses → 0, OOB 0, routes = 2; the next migration can copy ≥2 cookbook - entries. - -### Phase 3 — Remaining single-step "normal form" conversions -Lowest-complexity-first; each reuses the Phase-2 cookbook: -1. **Sales Summary Edit** (`pos/sales_summaries.clj`, ~780) -2. **Invoice Bulk Edit** (`invoices.clj`, ~700) — has account rows + totals; - exercises the targeted-totals-swap pattern from A directly. -3. **Transaction Edit** (`transaction/edit.clj`, ~1584) — swap mechanics already - solved in Phase 1; here we *also* convert it to pure fns + Selmer and drop the - wizard wrapper. Largest single-step; do it once the cookbook is rich. - -### Phase 4 — True multi-step wizards (build `wizard2` here) -Now build the data-driven engine (critique Tasks 1–2) — but only when the first -real wizard needs it, smallest-first: -1. **Transaction Rule** (2 steps, ~1005) — simplest real wizard; shakes out the - engine + server-side state (Decision 1). -2. **Invoice Pay** (2 steps) — conditional rendering by payment method. -3. **New Invoice** (3 steps) — the critique plan's proven 3-step reference. -4. **Vendor** (5 steps, ~917). -5. **Client** (7 steps, ~1913) — the monster, last, with the fullest skill. - -### Phase 5 — Cleanup -- Delete `multi_modal.clj` and the compatibility shim once the last caller is gone. -- Remove `@alpinejs/morph` if no longer referenced (A already removed it for - transaction-edit). -- Decide (Decision 3) whether to push Selmer into the remaining static Hiccup, - now that the skill makes it cheap. -- Final skill pass: promote recurring cookbook entries into shared Selmer - partials/components. +These are directional evidence, not targets to game. Pair them with the e2e +parity gate (§7) so "simpler" can never mean "broken." --- -## 9. Per-migration playbook (the repeatable loop) +## 7. Testing strategy -Every feature migration, regardless of phase, runs this loop. It belongs in -`SKILL.md`: +Consistent with the project's `testing-conventions` skill (test user-observable +behavior; assert DB state directly; don't test the means). -1. **Read the skill.** Note which cookbook entries and gotchas apply. -2. **Classify.** Single-step → normal form (no server state). Multi-step → wizard - (`wizard2` config + server state). When in doubt, it's a form. -3. **Baseline the scorecard (§4).** Record the before-numbers (LOC, cursor-uses, - OOB count, route count, complexity proxy) so the after-numbers mean something. -4. **Characterize current behavior (test-first).** *Before touching code*, write/ - confirm an e2e spec capturing user-observable behavior (focus/caret, - round-trip, validation, the actual DB mutation). This is the parity contract. -5. **Extract pure render functions** from cursor/protocol code — this is where - heuristics 1 & 2 (kill form-cursor faking and implicit merges) get paid. -6. **Templatize in Selmer** — convert those pure fns to templates; pull repeated - bits from the cookbook; add any new ones back. This is where heuristic 5 - (reuse) and 8 (attr consistency) get paid. -7. **Wire HTMX per the swap doctrine** (§Decision 2): no-request / targeted / - whole-form; focus invariant intact; OOB only for genuinely disjoint regions - (else hoist to a common ancestor). Heuristic 7. -8. **Routes:** collapse the 4–20 middleware-stacked routes to 2 (open/GET + - submit/POST), or +1 for an add-row endpoint. Heuristic 6. -9. **Verify:** run the feature's e2e spec *and* the full suite (must stay ≥27/2); - assert the DB mutation directly per `testing-conventions`; REPL-check pure fns - with `clojure-eval`. Re-measure the scorecard — no metric may regress. -10. **Commit** one reversible feature commit. Message records reused vs. new - cookbook entries and the scorecard delta (§7). -11. **Feed the skill.** Update cookbook / gotchas / test-recipes / scorecard / - SKILL.md. *This step is not optional.* +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. --- -## 10. Risk register +## 8. Per-migration playbook (the repeatable loop) + +This is the canonical loop each modal phase follows; it lives in `SKILL.md`. +Modal phases below list only what is *specific* to that modal plus this loop. + +1. [ ] **Read the skill.** Note applicable cookbook entries and gotchas. +2. [ ] **Classify.** Single-step → plain form (no server state). Multi-step → + wizard (engine + server state). When in doubt, it's a form. +3. [ ] **Baseline the scorecard (§6).** Record before-numbers. +4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec. +5. [ ] **Extract pure render functions** (kills cursor faking and `*-no-cursor*` + duplicates — heuristics 1, 2). +6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back + (heuristics 5, 8). +7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB + only for disjoint regions (heuristic 7). +8. [ ] **Collapse routes** to 2 (+1 for add-row) (heuristic 6). +9. [ ] **Verify:** modal e2e + full suite green; assert DB mutations; REPL-check + pure fns. Re-measure scorecard — no regressions. +10. [ ] **Commit** one reversible feature commit; message includes the scorecard + delta and reused/new cookbook entries. +11. [ ] **Feed the skill** (cookbook / gotchas / test-recipes / scorecard / + SKILL.md). *Not optional.* + +--- + +## 9. Phases & tasks + +> Migration target inventory (verify line counts at execution time): + +| Modal | File | Steps | Target | Phase | +|-------|------|-------|--------|-------| +| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | form | 2 (skill trial) | +| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | form | 3 | +| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | form | 4 | +| Invoice Bulk Edit | `invoices.clj` | 1 | form | 5 | +| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | 6 | +| Invoice Pay | `invoices.clj` | 2 | wizard | 7 | +| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | 8 | +| Vendor | `admin/vendors.clj` | 5 | wizard | 9 | +| Client | `admin/clients.clj` | 7 | wizard | 10 | + +--- + +### Phase 1 — Distil the skill (no app code changes) + +**Rationale:** the transaction-edit modal has already been migrated to the +whole-form swap approach successfully. Capture that working method as a skill +*now*, so every later migration is cheaper and consistent. (If the reference +implementation is not yet on the working branch, merge it first — that is an +acceptable prerequisite.) + +- [ ] Create `.claude/skills/ssr-form-migration/SKILL.md` with the playbook (§8). +- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus + invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit + swaps as worked examples. +- [ ] Write `reference/pure-render.md` from §3.2. +- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine). +- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in + Phase 2." +- [ ] Seed `component-cookbook.md` with whatever transaction-edit already proved + (e.g. the hardened typeahead, the totals-in-sibling-`` pattern). +- [ ] Seed `gotchas.md` (stale `$refs`, key-by-value). +- [ ] Seed `test-recipes.md`; record the **current full e2e pass/fail baseline**. +- [ ] Create `scorecard.md` with the §6 table and an empty results table. +- [ ] **Exit criteria:** an agent can read `SKILL.md` and the references and + understand the whole method without this plan. + +--- + +### Phase 2 — Trial the skill on Transaction Edit (first test subject) + +**Rationale:** validate the freshly written skill against the one modal whose +"correct" outcome we already know. This is also where Selmer + pure functions +are completed for this modal and the Selmer conventions get written from a real, +verified example. Target type: **plain form** (single step with a mode toggle — +the toggle is just a `GET` with a `?mode=` query param that re-renders the form). + +**Foundation (do once, here):** +- [ ] Add the `selmer` dependency to `project.clj`. +- [ ] Build the render helper (`selmer/render-file`) and the **interop bridge** + (Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup). +- [ ] Prove interop: a throwaway Selmer page renders inside the existing layout, + and a Hiccup component renders inside a Selmer template. + +**Modal migration (run the §8 loop), specifics:** +- [ ] Confirm/author the characterization e2e spec covering: typing in memo keeps + focus; selecting an account updates only its Location options; changing vendor + / adding / removing a row / toggling mode / toggling $-vs-% re-renders the + whole form correctly; amount edits update totals without losing the amount + caret; save round-trips. +- [ ] Extract pure render fns: `render-simple-fields`, `render-advanced-fields`, + `account-row`, `account-totals` (remove any `*-no-cursor*` duplicates). +- [ ] Convert those render fns to Selmer templates; record each as a cookbook + entry; finalize `selmer-conventions.md`. +- [ ] Verify the swaps match the doctrine (whole-form for structural changes, + targeted cell for account→location, sibling-`` for totals, no request + for memo); confirm `grep -c hx-swap-oob` is 0. +- [ ] Collapse routes: `GET /transaction/edit` (with `?mode=`), `POST + /transaction/edit`, plus the single `edit-form-changed` re-render endpoint. +- [ ] Verify (modal e2e + full suite green; DB save asserted). +- [ ] **Feed the skill:** refine `SKILL.md` and references from anything the + trial revealed; append the scorecard row (this is the baseline others beat). +- [ ] **Exit criteria:** skill-driven migration reproduces the known-good + behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries. + +--- + +### Phase 3 — Transaction Bulk Code (plain form) + +**Rationale:** the smallest *fresh* modal — first real test of "read the skill, +apply it cold." Single-step form currently wearing a wizard costume. + +- [ ] Run the §8 loop. +- [ ] Classify as plain form; delete the wizard protocol/record and snapshot. +- [ ] Extract `render-bulk-code-fields`; reuse cookbook typeahead/money-input. +- [ ] Search params preserved as plain hidden fields (no EDN snapshot). +- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit). +- [ ] Verify bulk-code applies correctly (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. +- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, cursor-use + all down vs. baseline. + +--- + +### Phase 4 — Sales Summary Edit (plain form) + +**Rationale:** another single-step form; reinforces the cold-apply loop. + +- [ ] Run the §8 loop. +- [ ] Classify as plain form; remove wizard record + `wrap-init-multi-form-state`. +- [ ] Extract `render-sales-summary-fields` (pure); reuse cookbook entries. +- [ ] Collapse 3 wizard routes → 2. +- [ ] Verify edit saves (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 5 — Invoice Bulk Edit (plain form with rows + totals) + +**Rationale:** first single-step form with dynamic account rows and live totals +— exercises the add-row endpoint and the totals-in-sibling-`` swap +(instead of OOB). + +- [ ] Run the §8 loop. +- [ ] Extract `bulk-edit-account-row` (pure); reuse the `account-row`/`totals` + cookbook entries from Phase 2. +- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the + sibling-`` swap, **not** OOB. +- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row). +- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. +- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are + confirmed reusable across two modals now. + +--- + +### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard) + +**Rationale:** the first genuinely multi-step modal, and the simplest one — the +right place to introduce the data-driven engine (§3.3) and server-side state. + +**Engine (do once, here):** +- [ ] Create `components/wizard_state.clj` (atom store, `create-wizard!`, + `update-step!`, `get-all`, `destroy!`, **TTL sweep** for abandoned wizards). + Test the lifecycle via REPL. +- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`, + `open-wizard`). Test render + step navigation. +- [ ] Document the engine usage in `reference/form-vs-wizard.md`. + +**Modal migration (run the §8 loop), specifics:** +- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a + results table); keep `validate-transaction-rule` as the step `:schema`/custom check. +- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`. +- [ ] Collapse routes → 2 (open, submit). +- [ ] Verify create / edit / run-test (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. +- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works. + +--- + +### Phase 7 — Invoice Pay (2-step wizard) + +**Rationale:** 2 steps with conditional rendering by payment method (e.g., +handwrite-check fields) — exercises the engine's `:next`/conditional branching. + +- [ ] Run the §8 loop. +- [ ] Extract `render-choose-method-step` and `render-payment-details-step`. +- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the + `invoice-by-id` lookup); branch `:next` on payment method. +- [ ] Collapse routes → 2. +- [ ] Verify each payment method path (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 8 — New Invoice (3-step wizard) + +**Rationale:** a true 3-step wizard with a conditional accounts step — the +reference multi-step shape. + +- [ ] Run the §8 loop. +- [ ] Extract `render-basic-details-step`, `render-accounts-step`, + `render-submit-step`; reuse the expense-account row cookbook entry. +- [ ] Define step schemas separately; `:next` from basic-details skips accounts + when not customizing. +- [ ] `:init-fn` sets defaults (e.g. date = now). +- [ ] Add-row for expense accounts via the sibling-`` totals pattern. +- [ ] Collapse routes → 2 (+1 add-row). +- [ ] Verify create with/without custom accounts (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 9 — Vendor (5-step wizard) + +**Rationale:** larger multi-step; by now the engine and cookbook are mature. + +- [ ] Run the §8 loop. +- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`, + `render-account-step`, `render-address-step`, `render-legal-step`. +- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn` + (empty vs. loaded entity). +- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit. +- [ ] Collapse routes → 2. +- [ ] Verify create + edit across all steps (assert DB) + full suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 10 — Client (7-step wizard) — largest, last + +**Rationale:** the biggest, most complex modal (nested bank accounts, location +matches, emails, contact methods). Deliberately last, when the skill is richest. + +- [ ] Run the §8 loop; split extraction into sub-tasks per step. +- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`, + `:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`). +- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches, + emails, contact methods) to an add-row `POST` using the cookbook row pattern; + drop `fc/with-field-default` nesting. +- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`. +- [ ] Collapse routes → 2 (+ add-row endpoints as needed). +- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full + suite green. +- [ ] Feed the skill; append scorecard row. + +--- + +### Phase 11 — Cleanup + +**Rationale:** remove the now-dead old machinery. + +- [ ] Delete the legacy wizard module (protocols + middleware) once no caller + remains; remove any v1→v2 shim. +- [ ] Remove the Alpine morph dependency/extension if unreferenced. +- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static + Hiccup, now that the skill makes it cheap. +- [ ] Promote recurring cookbook entries into shared Selmer partials/components. +- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop. + +--- + +## 10. Risks & mitigations | Risk | Mitigation | |------|------------| -| Server-restart loses in-flight wizard state | Confine server-side state to true multi-step wizards; normal forms hold no state. Add TTL + sweep; consider Datomic-backed store if a wizard is long-lived. | -| Mixed Hiccup/Selmer interop gets messy mid-transition | Build + test the interop bridge in Phase 0 before any feature migration. Strangler keeps both valid. | -| Selmer loses Hiccup's structural testability | Lean on e2e + DB-state assertions (already the project's testing-conventions stance); reserve unit tests for the pure data-prep functions that feed templates, not the markup. | -| Big files (`clients.clj` 1913, `edit.clj` 1584) hide behavior | They go *last*, after the skill is rich; characterization e2e specs first; split into sub-tasks per step. | -| Alpine components break across swaps | Codify A's hardening (null-guarded `tippy?`/`$refs`, key-by-server-value) as a cookbook entry applied to every interactive component. | -| Three concurrent changes per file obscure regressions | One feature per reversible commit; e2e parity gate before each merge. | -| Skill-update step gets skipped under time pressure | Make "reused/new cookbook + scorecard delta" a required line in the commit message; if migration N+1 isn't faster, flag it. | -| Selmer dep churn / unknowns | Spike it in Phase 0 on a throwaway page before betting features on it. | -| Heuristics get gamed (LOC golfing, route-count tricks) | They're directional evidence, not targets; pair every scorecard with the e2e parity gate so "simpler" can't mean "broken." Review the scorecard trend, not single numbers. | -| Quality regresses silently on a feature | Ratchet rule: no metric may regress for a touched feature; a regression blocks the migration unless an explicit exception is written in `gotchas.md`. | +| Server restart loses in-flight wizard state | Server state only for true multi-step wizards; forms hold none. TTL + sweep; consider a durable store if a wizard is long-lived. | +| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. | +| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. | +| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. | +| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. | +| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. | +| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. | +| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. | --- -## 11. Open decisions to confirm (owner: Bryce) +## 11. Open decisions -1. **Decision 1** — server-side state only for multi-step wizards, none for - normal forms? (recommended yes) -2. **Decision 2** — adopt the zero-OOB whole-form swap doctrine as canon, - overriding critique Tasks 5 & 11? (recommended yes) -3. **Decision 3** — Selmer hybrid (interactive leaves first) vs. full sweep? - (recommended hybrid) -4. **Decision 4** — do A+B+C together per feature on the shared pure-fn seam, - rather than three separate sweeps? (recommended yes) -5. **Scope check** — is landing `render-whole-form` (Phase 1) blocked on anything, - or can it merge now independent of the rest? - ---- - -## Appendix — source pointers - -- Swap doctrine commits: `git log integreat-render-hx-select` (worktree - `/home/noti/dev/integreat-render-whole-form`). Key files: `transaction/edit.clj`, - `components/inputs.clj`, `components/data_grid.clj` (`:footer-tbody`), - `e2e/transaction-edit-swap.spec.ts`, `resources/public/js/htmx-disable.js`. -- Wizard plan: `integreat-critique-wizard` worktree, - `docs/superpowers/plans/2025-01-15-wizard-refactor.md` (14 tasks) and - `2025-01-15-wizard-phase-i-trial.md` (the isolated trial). Spike code under - `src/clj/auto_ap/ssr/components/wizard_trial/` and - `src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj`. -- Attribute inconsistency evidence: `src/clj/auto_ap/ssr/components/inputs.clj` - on `staging` (e.g., `:x-ref` vs `"x-ref"`, `:x-model` vs `"x-model"`). +1. **Server state scope** — server-side state only for multi-step wizards, none + for plain forms? *(recommended: yes)* +2. **Selmer scope** — convert only interactive/attribute-heavy components first + (hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in + Phase 11)* +3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority + order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the + project default. *(recommended: yes)* +4. **First step** — start by distilling the skill (Phase 1) with the reference + implementation merged as a prerequisite, rather than treating the merge + itself as step one. *(recommended: yes)*