From 360847fa586e8b5c11609bcfcb5a3c880ed97fae Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 2 Jun 2026 21:39:04 -0700 Subject: [PATCH 1/5] docs: add SSR rendering modernization rollout plan Synthesize three SSR refactor exercises into one low-risk, compounding rollout plan: the render-whole-form HTMX swap doctrine, the critique-wizard architecture simplification, and a Hiccup -> Selmer templating migration. Includes a code-quality ratchet (per-migration scorecard), an explicit test-first strategy with an e2e regression gate, simplest-first phasing, and a self-reinforcing ssr-form-migration skill so each migration makes the next cheaper. Co-Authored-By: Claude Opus 4.8 --- ...factor-ssr-rendering-modernization-plan.md | 502 ++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md diff --git a/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md b/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md new file mode 100644 index 00000000..241f120c --- /dev/null +++ b/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md @@ -0,0 +1,502 @@ +# SSR Rendering Modernization — Rollout 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. + +--- + +## 1. Purpose + +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: + +> 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). + +--- + +## 2. What each workstream actually established + +### A. HTMX swap doctrine (`render-whole-form`) + +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`): + +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. + +**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. + +**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`. + +**Status:** near-complete, 27/2 e2e passing (the 2 failures are pre-existing and +unrelated). This is the most landable of the three. + +### B. Wizard architecture (`critique-wizard`) + +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: + +- **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. + +--- + +## 3. Key tensions to resolve before mass migration (DECISIONS) + +These are the cross-workstream conflicts. Each needs an explicit decision; my +recommended default is given but the call is yours. + +### Decision 1 — Server-side state vs. embedded snapshot + +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. + +**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. + +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. + +### Decision 2 — Swap doctrine is now canon, overriding the critique plan's OOB tasks + +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. + +**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.** + +### Decision 3 — Selmer scope: full vs. hybrid + +- **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. + +### Decision 4 — Sequence Selmer relative to B + +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: + +> 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). + +One pass per file, three wins. This is also what makes the compounding skill +coherent — every migration exercises the whole playbook. + +--- + +## 4. Code-quality ratchet (heuristics + per-migration scorecard) + +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. + +--- + +## 5. Testing strategy + +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): + +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):** + +``` +.claude/skills/ssr-form-migration/ + SKILL.md # the playbook (§9): decision tree + step-by-step + 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 +``` + +**The contract — every migration's last step is "feed the skill":** + +- 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. + +--- + +## 8. Phased rollout + +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. + +### 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). + +### 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. + +--- + +## 9. Per-migration playbook (the repeatable loop) + +Every feature migration, regardless of phase, runs this loop. It belongs in +`SKILL.md`: + +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.* + +--- + +## 10. Risk register + +| 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`. | + +--- + +## 11. Open decisions to confirm (owner: Bryce) + +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"`). -- 2.49.1 From a8d8a8d111dd0efe0186c581318b4a3f17b09ef4 Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 2 Jun 2026 21:56:12 -0700 Subject: [PATCH 2/5] 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)* -- 2.49.1 From 917b7f3857515da65c2212ede95895dbb7b35ffb Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 2 Jun 2026 22:02:25 -0700 Subject: [PATCH 3/5] docs: clarify cursors are fine; only faked positions are the smell Reframe goal 2, the rationale (2.2), the render-function pattern (3.2), and scorecard heuristic 1 so the target is top-rooted cursors. Cursors stay; what we remove is faking a cursor to start deeper in the tree and the duplicate *-no-cursor* variants that fakery forces. Co-Authored-By: Claude Opus 4.8 --- ...factor-ssr-rendering-modernization-plan.md | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 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 c0bbd723..6d5fa00c 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 @@ -15,11 +15,17 @@ skill* that makes the next migration cheaper. ## 1. Goals 1. **Render forms by re-rendering the whole form** (or a precise, isolated - fragment) over HTMX, instead of mutating the DOM in place. This removes the - class of bugs around stale state, lost focus/caret, and out-of-band patching. -2. **Make render functions pure.** A render function takes an explicit data map - and returns markup. No dynamic bindings, no "cursor" context, no duplicate - `*-no-cursor*` variants. + 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. @@ -44,13 +50,17 @@ form** and letting the typed value ride along in the form is simpler and correct, *provided the input the user is typing in is never inside the region being swapped*. -### 2.2 Cursor-based rendering forces duplicate functions -Render code that reads from dynamic bindings (a "form cursor") is -context-dependent and hard to test, which has spawned duplicate render functions -— one that reads the cursor and one that takes plain params: +### 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: needs cursor context (dynamic bindings *form-data* / *current* / *prefix*) +;; 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 @@ -58,11 +68,15 @@ context-dependent and hard to test, which has spawned duplicate render functions (account-typeahead* {:value (fc/field-value) :name (fc/field-name)}))) ...)) -;; SMELL: a second copy of the same markup, just to avoid the cursor +;; 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 @@ -163,9 +177,11 @@ state: (assoc attrs :key (str id "--" current-value)) ``` -### 3.2 Pure render functions +### 3.2 Render functions: explicit data, or a top-rooted cursor -One function, explicit data in, markup out: +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 @@ -180,8 +196,19 @@ One function, explicit data in, markup out: ...)) ``` -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. +```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) @@ -301,7 +328,7 @@ convention, e.g. `.claude/skills/testing-conventions/SKILL.md`). SKILL.md # the playbook (§8): classify → migrate → verify → record reference/ swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening - pure-render.md # §3.2 pure functions + thin cursor adapters + 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… @@ -331,7 +358,7 @@ the touched modal.** | # | Heuristic | Measure | Target | |---|-----------|---------|--------| -| 1 | Form-cursor / dynamic-binding usage | `grep -cE 'fc/with-field|\*form-data\*|\*current\*|\*prefix\*|-no-cursor'` | → 0 | +| 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 ↓ | @@ -377,8 +404,9 @@ Modal phases below list only what is *specific* to that modal plus this loop. 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). +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 @@ -423,7 +451,8 @@ acceptable prerequisite.) - [ ] 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/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." @@ -487,8 +516,8 @@ apply it cold." Single-step form currently wearing a wizard costume. - [ ] 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. +- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and + faked-cursor count all down vs. baseline. --- -- 2.49.1 From 0e02c489e0dcc8febb11d97710bf806c301c79b7 Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 2 Jun 2026 22:09:40 -0700 Subject: [PATCH 4/5] docs: multi-step wizards use session-stored step state (Django formtools) Replace the EDN snapshot + piecewise merge for multi-step wizards with per-step form state stored in the session, combined only at the end -- the Django formtools WizardView / SessionStorage model. Cite the inspiration and refs. Adds rationale 2.4, reworks the engine snippet in 3.3 to thread session state keyed by wizard-id (no snapshot, no merge), and updates goal 3, the Phase 6 engine tasks, the risk row, and Open decision 1 accordingly. Co-Authored-By: Claude Opus 4.8 --- ...factor-ssr-rendering-modernization-plan.md | 115 +++++++++++++----- 1 file changed, 83 insertions(+), 32 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 6d5fa00c..400b289e 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 @@ -28,7 +28,9 @@ skill* that makes the next migration cheaper. 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. + 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. @@ -83,7 +85,15 @@ serialize an EDN snapshot with custom readers into hidden fields, and register 10–20 routes with stacked middleware — all for a single-step form. That is pure overhead. -### 2.4 Hiccup makes Alpine/HTMX attributes ambiguous +### 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: @@ -220,7 +230,21 @@ deep node, and never keep a second `*-no-cursor*` copy of the markup. ::route/edit-submit (fn [req] (validate-and-save req))} ``` -- **Genuinely multi-step → data-driven engine.** A wizard is *data*: +- **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 @@ -231,41 +255,57 @@ deep node, and never keep a second `*-no-cursor*` copy of the markup. :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: + 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 - ;; 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))))) + ;; Storage backed by the Ring session (replaces the hidden EDN snapshot). + ;; Path in session: [:wizards :step-data ] + (defn create-wizard! [session config] + (let [id (str (java.util.UUID/randomUUID))] + [id (assoc-in session [:wizards id] + {:current-step (-> config :steps first :key) :step-data {}})])) - (defn render-wizard [{:keys [wizard-id config request]}] - (let [{:keys [current-step step-data]} (@store wizard-id) + (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 {})))])) - (defn handle-step-submit [config request] + ;; 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 :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})))))))) + (-> (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`). + (`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 @@ -556,15 +596,22 @@ apply it cold." Single-step form currently wearing a wizard costume. ### 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. +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` (atom store, `create-wizard!`, - `update-step!`, `get-all`, `destroy!`, **TTL sweep** for abandoned wizards). - Test the lifecycle via REPL. +- [ ] Create `components/wizard_state.clj` backed by the **Ring session**: + `create-wizard!`, `put-step` (replace step data, do **not** merge into a + snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is + namespaced by `wizard-id` inside the session (`[:wizards ...]`) so tabs + and concurrent wizards don't collide. Each fn returns the updated session for + the handler to thread into the Ring response. Test the lifecycle via REPL. - [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`, - `open-wizard`). Test render + step navigation. -- [ ] Document the engine usage in `reference/form-vs-wizard.md`. + `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 @@ -663,7 +710,7 @@ matches, emails, contact methods). Deliberately last, when the skill is richest. | Risk | Mitigation | |------|------------| -| Server restart loses in-flight wizard state | Server state only for true multi-step wizards; forms hold none. TTL + sweep; consider a durable store if a wizard is long-lived. | +| 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. | @@ -676,8 +723,12 @@ matches, emails, contact methods). Deliberately last, when the skill is richest. ## 11. Open decisions -1. **Server state scope** — server-side state only for multi-step wizards, none - for plain forms? *(recommended: yes)* +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)* -- 2.49.1 From d36031659052417a5880be9ed42e5928b268d31a Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 2 Jun 2026 23:17:52 -0700 Subject: [PATCH 5/5] docs: add swap-target selector strategy consideration Note in 3.1 that targeted hx-select/hx-target swaps in repeated/nested structures may want a consistent scheme -- semantic markup + data-attributes, or a form-path->selector helper (mirroring cursors) -- instead of hand-minting a unique id per element. Framed as a consideration for advanced cases, with a Phase 5 task to settle the convention into the skill cookbook. Co-Authored-By: Claude Opus 4.8 --- ...factor-ssr-rendering-modernization-plan.md | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) 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 400b289e..2e0648a5 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 @@ -187,6 +187,38 @@ state: (assoc attrs :key (str id "--" current-value)) ``` +**Selector strategy for targeted swaps (a consideration, not a mandate).** +Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach +— a unique `id` on every swappable element — gets noisy in repeated structures +(e.g. a table of financial accounts where choosing an account must swap *that +row's* dropdown). When you reach those advanced cases, consider a more +consistent scheme instead of hand-minting ids everywhere: + +- **Semantic markup + data-attributes** to craft a fine-grained selector without + per-element ids. For example, mark rows/cells with their identity and target + by attribute: + ```html + + + + + … + + ``` +- **A `form-path -> id` (or `-> selector`) function**, derived the same way a + cursor path is, so the server and the markup agree on the target by + construction rather than by convention. A render fn at form-path + `[:accounts 0 :location]` would compute its own stable selector (id or + data-attribute query) from that path, mirroring §3.2's top-rooted cursor. + +The aim is *consistency and predictability* of swap targets in repeated/nested +structures — pick whichever keeps targets unambiguous and easy to generate. Note +this in `reference/swap-doctrine.md` and let the first modal that hits nested +repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook. + ### 3.2 Render functions: explicit data, or a top-rooted cursor One function, data in, markup out. The data can arrive as a plain map or via a @@ -367,7 +399,8 @@ convention, e.g. `.claude/skills/testing-conventions/SKILL.md`). .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 + 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 @@ -585,6 +618,10 @@ apply it cold." Single-step form currently wearing a wizard costume. cookbook entries from Phase 2. - [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the sibling-`` swap, **not** OOB. +- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1 + "Selector strategy"): semantic data-attributes and/or a `form-path -> selector` + helper, rather than hand-minted ids per element. Record the chosen convention + in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it. - [ ] Collapse 4 wizard routes → 3 (open, submit, add-row). - [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green. - [ ] Feed the skill; append scorecard row. -- 2.49.1