docs: SSR rendering modernization rollout plan #12
@@ -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-<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 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"`).
|
||||
Reference in New Issue
Block a user