Capture the proven whole-form hx-select swap method as a reusable skill so every later modal migration is cheaper and consistent. No app code changes. - SKILL.md: the per-migration playbook (classify → baseline → characterize → consolidate render fns → templatize → wire HTMX → collapse routes → verify → commit → feed skill) + Growth contract + non-negotiables. - reference/swap-doctrine.md: the four swap rules, focus invariant, Alpine-survives- swap hardening, target-selector strategy — worked from the real edit.clj swaps (memo no-request, account→location targeted cell, amount→totals sibling-tbody, vendor/mode/row whole-form). 0 OOB. - reference/render-functions.md: explicit-data or top-rooted cursor; the MapCursor fake + transaction-account-row-no-cursor* twin as the smell to remove. - reference/form-vs-wizard.md: classification + the data-driven session-backed (formtools SessionStorage) engine that replaces the snapshot round-trip + protocol. - reference/selmer-conventions.md: STUB, validated in Phase 2. - component-cookbook.md / gotchas.md / test-recipes.md / scorecard.md: seeded from what transaction-edit proves (7 cookbook entries, caret-survival + typeahead test recipes, scorecard baseline LOC 1608 / ~12 routes / 1 no-cursor twin / 2 faked roots / 0 OOB). Scorecard (Transaction Edit baseline, before Phase 2): LOC 1608, routes ~12, no-cursor twins 1, faked-cursor roots 2, snapshot merges ~75, OOB 0, mixed hx- 8.
123 lines
6.4 KiB
Markdown
123 lines
6.4 KiB
Markdown
---
|
|
name: ssr-form-migration
|
|
description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, the data-driven session-backed wizard engine, and (where it helps) Selmer templates. Use when simplifying a server-rendered form/wizard modal in this codebase, removing EDN-snapshot round-trips, deleting *-no-cursor* duplicate render fns, collapsing per-interaction routes, or replacing the multi-step wizard protocol machinery.
|
|
---
|
|
|
|
# SSR Form & Wizard Migration
|
|
|
|
A repeatable method for making a server-rendered form/wizard modal **simpler** without
|
|
changing user-facing behavior. Distilled from the first proven migration — the
|
|
`transaction/edit.clj` modal, which already runs on the whole-form `hx-select` swap
|
|
approach with **zero out-of-band swaps**. Every migration *reads this skill first* and
|
|
*extends it last* (the Growth contract below). If migration N+1 is not easier than N,
|
|
the skill-update step was skipped — treat that as a bug.
|
|
|
|
The four patterns every migration moves code toward live in `reference/`:
|
|
|
|
- `reference/swap-doctrine.md` — the four-rule HTMX swap priority order + the focus
|
|
invariant + Alpine-survives-swap hardening + target-selector strategy.
|
|
- `reference/render-functions.md` — one render fn per component, taking explicit data
|
|
**or a top-rooted cursor**; no faked cursor positions, no `*-no-cursor*` twins.
|
|
- `reference/form-vs-wizard.md` — single-step → plain form; multi-step → data-driven
|
|
engine with **per-step state in the Ring session** (the Django `formtools` model).
|
|
- `reference/selmer-conventions.md` — plain-HTML attributes via Selmer, the
|
|
Hiccup↔Selmer interop bridge, include/block patterns.
|
|
|
|
Growing cookbooks (append every migration):
|
|
`component-cookbook.md`, `gotchas.md`, `test-recipes.md`, `scorecard.md`.
|
|
|
|
---
|
|
|
|
## The per-migration playbook
|
|
|
|
Run this loop for each modal. The phase notes in the migration plan list only what is
|
|
*specific* to a modal; this loop is the constant.
|
|
|
|
1. **Read the skill.** Skim `reference/` and note which `component-cookbook.md`
|
|
entries and `gotchas.md` you can reuse. Start from the cookbook, not a blank file.
|
|
|
|
2. **Classify** (`reference/form-vs-wizard.md`).
|
|
- Single logical step (even with a `?mode=` toggle or add/remove rows) → **plain
|
|
form**: no server-side wizard state, no snapshot, no protocol.
|
|
- Genuinely multiple steps the user advances through → **wizard**: the data-driven
|
|
engine + per-step session storage.
|
|
- When in doubt, it's a form.
|
|
|
|
3. **Baseline the scorecard** (`scorecard.md`, heuristics in §6 of the plan). Record
|
|
before-numbers with cheap tools:
|
|
```bash
|
|
F=src/clj/auto_ap/ssr/<modal>.clj
|
|
wc -l $F # LOC (heuristic 4)
|
|
grep -c 'defn.*-no-cursor' $F # *-no-cursor* twins (heuristic 1)
|
|
grep -cE 'with-cursor|MapCursor\.' $F # faked cursor re-roots (heuristic 1)
|
|
grep -c 'hx-swap-oob' $F # OOB swaps (heuristic 7)
|
|
grep -cE '"hx-[a-z]' $F # mixed string hx- attrs (heuristic 8)
|
|
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
|
|
```
|
|
|
|
4. **Characterize behavior (test-first).** Write or confirm a Playwright spec that
|
|
captures *current* behavior before you touch anything — focus/caret survival across
|
|
swaps, each field round-trip, validation errors, and the real save. This spec is the
|
|
parity contract; it must stay green through every commit. See `test-recipes.md`.
|
|
|
|
5. **Consolidate render functions** (`reference/render-functions.md`). Make each render
|
|
fn take explicit data or a **top-rooted cursor**. Delete `*-no-cursor*` duplicates
|
|
and any `with-cursor`/`MapCursor` rebind that fakes a deep starting position
|
|
(heuristics 1, 2). Using a cursor is fine; faking where it *starts* is not.
|
|
|
|
6. **Templatize in Selmer** (`reference/selmer-conventions.md`) where the component is
|
|
interactive/attribute-heavy. Reuse cookbook bits; add new ones back (heuristics 5, 8).
|
|
Static markup may stay Hiccup — Selmer scope is hybrid (Open decision 2).
|
|
|
|
7. **Wire HTMX per the swap doctrine** (`reference/swap-doctrine.md`). Keep the focus
|
|
invariant intact. No OOB except a genuinely disjoint region, documented (heuristic 7).
|
|
|
|
8. **Collapse routes** to 2 (`GET` open, `POST` submit) — `+1` for an add-row endpoint,
|
|
`+1` for the single `*-form-changed` whole-form re-render endpoint (heuristic 6).
|
|
|
|
9. **Verify.** Modal e2e green + full e2e suite at-or-above baseline; assert DB
|
|
mutations by querying Datomic, not markup; REPL-check the pure render/data fns.
|
|
Re-measure the scorecard — **no metric may regress for the touched modal** without a
|
|
written exception in `gotchas.md`.
|
|
|
|
10. **Commit** one reversible feature commit. The message includes the scorecard delta
|
|
and the reused/new cookbook entries.
|
|
|
|
11. **Feed the skill** (the Growth contract). *Not optional.*
|
|
|
|
---
|
|
|
|
## 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 or wrong? → fix this `SKILL.md`.
|
|
- Measured the scorecard? → append the row to `scorecard.md`.
|
|
|
|
**Success signal:** each migration reuses more cookbook entries and starts from a better
|
|
scorecard baseline than the previous one.
|
|
|
|
---
|
|
|
|
## Non-negotiables
|
|
|
|
- **Focus invariant:** the input the user is typing in is *never* inside the region its
|
|
own request swaps. Violating this drops the caret. (Proven by the
|
|
`transaction-edit-swap.spec.ts` caret tests.)
|
|
- **No new OOB swaps.** If tempted to OOB something inside the same feature, restructure
|
|
the DOM so the dependent element shares an ancestor with the trigger and use an
|
|
ordinary swap (e.g. totals in a sibling `<tbody>`).
|
|
- **Behavior parity is proven by tests, not by reading.** The full e2e suite stays green
|
|
after every migration.
|
|
- **Don't game the heuristics.** They're directional evidence paired with the e2e parity
|
|
gate; review the trend, not single numbers.
|
|
|
|
## Project conventions that bite (see `gotchas.md`)
|
|
|
|
- Edit Clojure with the clojure-mcp tools (`clojure_edit`, `clojure_edit_replace_sexp`),
|
|
not the raw file editor. `clj-paren-repair` then `lein cljfmt fix` when a file won't
|
|
compile.
|
|
- Run tests via the `clojure-eval` skill / `clj-nrepl-eval -p PORT`, not `lein test`.
|
|
- Temp files go in `./tmp/`.
|