Files
integreat/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md
Bryce 360847fa58 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 <noreply@anthropic.com>
2026-06-02 21:39:04 -07:00

503 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SSR 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-<idx>` / `#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
`<tbody id="account-totals">` (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 `<tbody id="account-totals">` 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 14 (§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 12) — 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 420 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"`).