diff --git a/.claude/skills/ssr-form-migration/reference/component-cookbook.md b/.claude/skills/ssr-form-migration/reference/component-cookbook.md
index dcfcddb1..af778820 100644
--- a/.claude/skills/ssr-form-migration/reference/component-cookbook.md
+++ b/.claude/skills/ssr-form-migration/reference/component-cookbook.md
@@ -149,3 +149,34 @@ divergence). Scorecard heuristic 1: faked roots → 0.
```
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
dedicated route.
+
+---
+
+## The Selmer component library (`auto-ap.ssr.components.selmer` / `sc`) — Phase 2-final
+
+Every shared component the modal renders through is now a thin Clojure wrapper over a
+partial under `resources/templates/components/`. **Reuse these before reaching for the
+Hiccup `com/*` versions in a migrated modal.** Each wrapper builds a context (reusing the
+real class helpers so output matches modulo Tailwind order) and renders its own partial via
+the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str` →
+`{{ attrs|safe }}`. See `selmer-conventions.md` for the mechanics.
+
+| Wrapper | Partial | Notes |
+|---------|---------|-------|
+| `sc/hidden` / `sc/text-input` / `sc/money-input` | `hidden`/`text-input`/`money-input`.html | leaf inputs; class via `inputs/default-input-classes` + `use-size` |
+| `sc/validated-field` | `validated-field.html` | label + body + always-present error `
`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
+| `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` |
+| `sc/badge` / `sc/link` | `badge`/`link`.html | |
+| `sc/button-group` / `sc/button-group-button` | `button-group(+button).html` | the group does **not** mutate children's classes (the Hiccup `group-` added rounded-l/r) — add rounding in the caller/template (tabs do) |
+| `sc/radio-card` | `radio-card.html` | reproduces the `select-keys [:hx-post :hx-target :hx-swap :hx-include :hx-trigger]` filter (drops `:hx-vals`/`:hx-select`) **and** the dangling-`[:h3]` quirk: only the `
` renders |
+| `sc/data-grid` (+ `-header`/`-row`/`-cell`) | `data-grid*.html` | table shell + optional `footer-tbody` (the swappable totals tbody) |
+| `sc/typeahead` | `typeahead.html` | full Alpine + tippy; resolves `{value,label}` server-side via `content-fn`; every `tippy?.` null-guard preserved; hidden posting `` with `:value="value.value"` + the `x-init` watcher |
+| `sc/modal` | `modal.html` | the `@click.outside="open=false"` wrapper |
+| SVGs | `spinner`/`svg-x`/`svg-external-link`/`svg-drop-down`.html | static, `{% include %}`d so the markup isn't duplicated |
+
+Modal-specific structure lives under `resources/templates/transaction-edit/`
+(`edit-form`, `edit-modal`, `links-body`, `manual-coding`, `simple-mode`, `account-totals`,
+`details-panel`, the four match panels, `transitioner`). The render fns in `edit.clj`
+gather data, call `sc/*`, and interpolate the fragments into these layout templates as
+`{{ frag|safe }}`. **Verify each wrapper by class-set equality + e2e, never byte-parity**
+(`hh/add-class` is set-based, so class order differs from the Hiccup output).
diff --git a/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md b/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
index b4228144..213d5942 100644
--- a/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
+++ b/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
@@ -13,9 +13,13 @@ implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an
snapshot into hidden fields, and register 10–20 stacked-middleware routes — all for one
step. That is pure overhead to delete.
+> **Done — Transaction Edit is now a plain form.** `LinksStep`/`EditWizard` and all `mm/*`
+> usage were deleted from `transaction/edit.clj`; the worked example below is realized, not
+> aspirational. See "Single-step → plain form (realized)".
+
## The machinery being replaced
-`transaction/edit.clj` today still carries the old shape, useful as the "before":
+The old shape (kept here as the "before"):
```clojure
(defrecord LinksStep [linear-wizard]
@@ -49,6 +53,33 @@ A `?mode=` toggle is just the `GET` re-rendering with a different query param
plain form. An add-row interaction is one extra `POST` that appends a fresh row and
re-renders (the `+1` route).
+### Single-step → plain form (realized: Transaction Edit)
+
+What replacing the wizard actually looked like, end to end:
+
+1. **Delete the records + middleware.** `EditWizard`/`LinksStep`, `mm/open-wizard-handler`,
+ `mm/next-handler`, `mm/submit-handler`, `mm/wrap-wizard`, `mm/wrap-decode-multi-form-state`,
+ and the `edit-wizard-navigate` route all go. `render-step` becomes a plain `render-form`.
+2. **Rename the fields off `step-params[...]`.** Field names are now the schema path
+ directly (`(path->name2 :transaction/accounts 0 :transaction-account/account)` →
+ `transaction/accounts[0][transaction-account/account]`). They decode straight into the
+ form schema via the unchanged `wrap-nested-form-params` + `mc/decode` — no two-key
+ snapshot/step-params decode. **Strip stray keys after decode** (`select-keys` to the
+ schema's keys) or a non-schema input like the tab group's `method` hidden 500s the save
+ (see `gotchas.md`).
+3. **Flat state.** `wrap-derive-state` builds a plain `{:snapshot :edit-path :step-params}`
+ map (not the `MultiStepFormState` record): `entity-only` fields from the entity, editable
+ fields from the live posted form (absent = cleared). The ~34 `:snapshot` reads keep
+ working.
+4. **Validation/error flow without `wrap-ensure-step`.** Reuse the generic
+ `wrap-form-4xx-2` directly: `(-> submit-edit (wrap-form-4xx-2 render-form-response) …)`.
+ `submit-edit` runs `assert-schema` then dispatches the save; on a throw, `wrap-form-4xx-2`
+ re-renders the whole form with `:form-errors` keyed by schema paths. A `*errors*` dynamic
+ var (bound by `render-form`) replaces the form-cursor's `*form-errors*` for field lookups.
+5. **Routes shrink to** `edit-wizard` (GET open), `edit-submit` (POST), `edit-form-changed`
+ (POST whole-form re-render for dependent changes), `location-select` (GET),
+ `unlink-payment` (POST).
+
---
## Genuinely multi-step → data-driven engine with session-stored step state
diff --git a/.claude/skills/ssr-form-migration/reference/gotchas.md b/.claude/skills/ssr-form-migration/reference/gotchas.md
index 446ae4e2..e9f9fc26 100644
--- a/.claude/skills/ssr-form-migration/reference/gotchas.md
+++ b/.claude/skills/ssr-form-migration/reference/gotchas.md
@@ -148,6 +148,52 @@ hides every test after the first failure, so fixing one unmasks the next):
Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs
are present immediately.
+## Flat decode leaks stray form fields into the saved entity (the `method` 500)
+
+Dropping the wizard's `step-params[...]` field-name prefix and decoding posted params
+**straight into the form schema** means the decode now captures **every** posted field, not
+just the namespaced ones. A single stray field breaks the save:
+
+- The tab switcher is `(com/button-group {:name "method"} …)`, which emits
+ ``. Under the wizard, `method` lived *outside*
+ `step-params[...]` so it never entered the decoded map. After the rename it decodes to
+ `:method ""` (malli `:map` is open and passes unknown keys), rides into `snapshot` →
+ `tx-data`, and `:upsert-transaction` rejects it → **HTTP 500 on save**.
+- Symptom: the save POST fires (confirm with a `println` in the submit handler) but the
+ modal never closes, because the 500 trips `htmx:response-error`. The server error may go
+ to mulog, not stdout — an empty stdout log does **not** mean "no error." Reproduce the
+ exact POST with `curl` (add/remove one field) to isolate the offender fast.
+
+**Fix:** strip the decoded map to the schema's known top-level keys before threading on
+(`select-keys decoded edit-form-keys`); keep that allowlist next to the schema. Nested
+account sub-maps decode fine — only the top level needs the guard.
+
+## REPL reload does not refresh a running jetty's routes — restart the JVM
+
+`handler/match->handler-lookup` is a top-level `def` capturing `(merge ssr/key->handler …)`
+at load, through a chain of module-level `def`s (`edit` → `ssr.transaction` → `ssr.core` →
+`handler`). Reloading the leaf `edit.clj` updates it but **not** the captured merges, and a
+jetty started `(run-jetty app …)` holds a static `app` that doesn't re-deref the lookup per
+request. Net: after a handler/route/record change, an already-running dev server keeps
+serving the **old** code — `curl` shows the pre-change response (e.g. the old wizard
+transitioner) while your REPL renders the new one. **Restart in a fresh JVM** for
+route/record/middleware changes. For e2e, the Playwright test server
+(`lein run -m auto-ap.test-server`) is a fresh JVM compiling from disk — but kill any stale
+`:3333` first (`reuseExistingServer` reuses it), and kill **by port**
+(`ss -tlnp | grep :3333`), never `pkill -f test-server` (it matches its own command line).
+
+## Full-suite e2e flakes are shared-seed interference
+
+The test server seeds once at boot; edit tests **save** (mutate) those seed transactions.
+Run in parallel, workers race the same rows and earlier saves pollute later reads → phantom
+failures that pass in isolation. Clean signal: restart (re-seed) + **`--workers=1`**.
+Baseline is **38 pass / 1 fail**, the 1 being the pre-existing
+`transaction-navigation.spec.ts:92` date-range test (unrelated to the edit modal).
+
## Scorecard exceptions (ratchet violations with a reason)
-_None yet._ Append here if a migration must let a metric regress for a documented reason.
+**Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save
+`com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines.
+They are terminal responses (shown after the form closes), reuse a shared dialog component,
+and sit outside the form's interactive render path. Migrating them means porting the shared
+`success-modal` to Selmer — a Phase 11 cross-cutting task, not a single-modal one.
diff --git a/.claude/skills/ssr-form-migration/reference/scorecard.md b/.claude/skills/ssr-form-migration/reference/scorecard.md
index 43583a21..f74491cf 100644
--- a/.claude/skills/ssr-form-migration/reference/scorecard.md
+++ b/.claude/skills/ssr-form-migration/reference/scorecard.md
@@ -40,6 +40,14 @@ Each migration appends one row (after-numbers), referencing the before in the di
|-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------|
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
+| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** |
+
+### New heuristics introduced at 2-final (full Selmer)
+
+| # | Heuristic | Measure | Target |
+|---|-----------|---------|--------|
+| 9 | Hiccup HTML tags in the render path | `grep -cE '\[:(div\|span\|p\|a\|button\|input\|h[1-6]\|ul\|li\|label\|select\|option\|t(able\|head\|body\|r\|d\|h)\|form\|svg\|template)'` over the modal's render fns | → 0 (success-modal confirmation dialogs may keep the shared Hiccup component) |
+| 10 | mm wizard coupling | `grep -c 'mm/' the modal file` + `grep -c 'defrecord.*Wizard\|ModalWizardStep'` | → 0 for a single-step modal |
> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
@@ -59,3 +67,18 @@ Each migration appends one row (after-numbers), referencing the before in the di
> Selmer sweep of the shared `com/typeahead`/`com/select`/`com/button-group-button` (those
> shared call sites hold the last 8 mixed `@`/`:`-attr offenders; they clear when the
> shared components move to Selmer — not a single-modal task, per Open decision 2).
+
+> **Phase 2-final — full Selmer + wizard removed.** Every component the modal renders
+> through was ported to a Selmer partial under `resources/templates/components/` with a
+> thin Clojure wrapper in `auto-ap.ssr.components.selmer` (`sc/*`); the modal's own
+> structure lives under `resources/templates/transaction-edit/`. The `mm` wizard
+> abstraction (`EditWizard`/`LinksStep` records, `MultiStepFormState`, `step-params[...]`
+> field names, `wrap-wizard`/`wrap-decode-multi-form-state` middleware) was deleted — there
+> was only ever one step, so it was pure overhead. Result: heuristic 8 (mixed hx-) and 9
+> (Hiccup in render) and 10 (mm coupling) all → **0**; the `edit-wizard-navigate` route is
+> gone (routes 5). Parity held: swap spec **6/6**, transaction-edit spec **8/8**, full
+> suite **38 pass / 1 pre-existing unrelated fail** (serial, fresh seed). The only Hiccup
+> left in the file is the post-save `com/success-modal` confirmation dialogs (terminal,
+> shared component — out of the form's render path). See `form-vs-wizard.md` (drop-the-
+> wizard test), `selmer-conventions.md` (composition mechanics), and `gotchas.md`
+> (stray-field decode leak; jetty reload staleness).
diff --git a/.claude/skills/ssr-form-migration/reference/selmer-conventions.md b/.claude/skills/ssr-form-migration/reference/selmer-conventions.md
index 31116e3b..85487bb8 100644
--- a/.claude/skills/ssr-form-migration/reference/selmer-conventions.md
+++ b/.claude/skills/ssr-form-migration/reference/selmer-conventions.md
@@ -75,11 +75,60 @@ Lessons:
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
-## Composition
+## Composition — verified mechanics (selmer 1.12.61)
-Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component templates
-referenced by classpath path. Keep `|safe` to values the server fully controls (rendered
-Hiccup, JSON for `x-data`), never raw user input.
+Proven by REPL before the full migration (do the same before relying on any of these):
+
+- **`{% include %}` works in FILE renders only.** `sel/render` = `selmer/render-file`, and
+ include/extends/block are *parse-stage* tags. Rendering a template **string** that
+ contains `{% include %}` throws `unrecognized tag: :include` (the runtime expr-tag has a
+ nil handler). So includes only work from a `.html` file, never from `render-str`.
+- **`{% include %}` sees `{% for %}` loop bindings.** Inside `{% for row in rows %}…{% include "components/x.html" %}…{% endfor %}` the partial can read `row.*`. Good for repeated
+ rows — though Clojure-composing the rows (below) is usually simpler.
+- **`{% include … with k=v %}` is IGNORED** in 1.12.61 (the `with` clause is dropped). To
+ parametrize, either wrap with the block tag `{% with k=v %}{% include … %}{% endwith %}`
+ (works), or — preferred — let a Clojure wrapper render the partial with an explicit ctx.
+
+## The Clojure-wrapper composition pattern (`auto-ap.ssr.components.selmer` / `sc`)
+
+Because `{% include with %}` can't pass args and the server computes most values anyway,
+each shared component is a **thin Clojure wrapper that renders its own partial** (the
+proven `location-select*` shape, generalised). The element *structure* lives 100% in the
+`.html`; the only Clojure is data assembly. The modal's render fns compose these wrappers
+and assemble a view-model, interpolating the pre-rendered fragments as `{{ frag|safe }}`.
+
+```clojure
+(sc/hidden {:name … :value …}) ; -> render "components/hidden.html"
+(sc/validated-field {:label … :errors …} body…)
+(sc/typeahead {:name … :url … :value … :content-fn …}) ; resolves label server-side
+(sc/data-grid {:headers […] :footer-tbody …} rows…)
+```
+
+### `attrs->str` — the dynamic-attribute bridge
+
+HTMX/Alpine attributes vary per call site, so a fixed template can't enumerate them.
+`sc/attrs->str` serialises an attribute *map* to an HTML attribute *string* injected with
+`{{ attrs|safe }}`: ``. Rules mirror hiccup2 — nil/false
+dropped, `true` → bare boolean attr, else `name="escaped"` (via `hu/escape-html`, so JSON
+`x-data` and `x-init` quotes become `"`/`'` and the browser decodes them back).
+Keyword keys keep their full name incl. namespaced/colon forms (`:x-hx-val:account-id` →
+`x-hx-val:account-id`). This keeps templates free of per-attribute `{% if %}` ladders while
+still emitting 100% Selmer markup — `attrs->str` serialises data, it does not build Hiccup.
+
+### Reuse the real class helpers
+
+Wrappers reuse `inputs/default-input-classes`, `inputs/use-size`, `hh/add-class`,
+`btn/bg-colors` so output matches the Hiccup component **modulo Tailwind class order**.
+Verify by class-**set** equality + e2e, never byte-parity (see the cookbook entries).
+
+### Trivial wrapper divs
+
+A bare `
…
` around a fragment is composed with a `wrap-div` string
+helper (or put the class in the parent template), not a Hiccup vector — string composition
+of a structural wrapper is not Hiccup and avoids a micro-template per div.
+
+Keep `|safe` to values the server fully controls (rendered fragments, JSON for `x-data`),
+never raw user input.
## Scope (Open decision 2)
diff --git a/e2e/transaction-edit-swap.spec.ts b/e2e/transaction-edit-swap.spec.ts
index 8d1a251e..824594d8 100644
--- a/e2e/transaction-edit-swap.spec.ts
+++ b/e2e/transaction-edit-swap.spec.ts
@@ -5,7 +5,7 @@ import { test, expect } from '@playwright/test';
// re-renders the entire form, and the client selects what to swap back -- with
// no out-of-band swaps and no morph extension:
// - discrete changes (vendor, account, location, mode, add/remove row) swap
-// all of #wizard-form (the active action/tab round-trips through the form,
+// all of #edit-form (the active action/tab round-trips through the form,
// so it survives the swap);
// - typed fields never swap the input the user is in -- the amount field swaps
// only the #account-totals tbody (a sibling of the input rows), and the memo
@@ -32,7 +32,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) {
.nth(transactionIndex)
.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
- await page.waitForSelector('#wizardmodal');
+ await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
// First transaction has no accounts so it opens in "simple" mode. Switch to
@@ -107,7 +107,7 @@ test.describe('Transaction Edit whole-form swap', () => {
.toBeGreaterThan(0);
// The form must survive the swap intact.
- await expect(page.locator('#wizard-form')).toHaveCount(1);
+ await expect(page.locator('#edit-form')).toHaveCount(1);
expect(errors, errors.join('\n')).toEqual([]);
});
@@ -192,7 +192,7 @@ test.describe('Transaction Edit whole-form swap', () => {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
- await page.waitForSelector('#wizardmodal');
+ await page.waitForSelector('#editmodal');
const memo = page.locator('#edit-memo');
await memo.waitFor();
@@ -301,7 +301,7 @@ test.describe('Transaction Edit whole-form swap', () => {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
- await page.waitForSelector('#wizardmodal');
+ await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
@@ -350,7 +350,7 @@ test.describe('Transaction Edit whole-form swap', () => {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
- await page.waitForSelector('#wizardmodal');
+ await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
diff --git a/e2e/transaction-edit.spec.ts b/e2e/transaction-edit.spec.ts
index fb4634d2..1d63aa8d 100644
--- a/e2e/transaction-edit.spec.ts
+++ b/e2e/transaction-edit.spec.ts
@@ -13,7 +13,7 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
// Wait for the modal to open
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
- await page.waitForSelector('#wizardmodal');
+ await page.waitForSelector('#editmodal');
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
// the manual account coding form is active.
@@ -144,7 +144,7 @@ async function saveTransaction(page: any) {
}
async function toggleToPercentMode(page: any) {
- const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]');
+ const percentRadio = page.locator('input[name="amount-mode"][value="%"]');
await percentRadio.click();
// Wait for HTMX to swap the grid body
@@ -155,7 +155,7 @@ async function toggleToPercentMode(page: any) {
}
async function toggleToDollarMode(page: any) {
- const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
+ const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
await dollarRadio.click();
// Wait for HTMX to swap the grid body
@@ -235,7 +235,7 @@ test.describe('Transaction Edit Full Workflow', () => {
await openEditModal(page, 0);
await page.waitForTimeout(500);
- const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
+ const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
await expect(dollarRadio).toBeChecked();
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
@@ -272,7 +272,7 @@ test.describe('Transaction Edit Validation', () => {
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
// The form should still be present
- const form = page.locator('#wizard-form');
+ const form = page.locator('#edit-form');
await expect(form).toBeVisible();
// Verify the account row is still there with our $50 value
@@ -304,7 +304,7 @@ async function openEditModalForTransaction(page: any, description: string) {
// navigation), so the action tabs -- including "Link to payment" -- are available
// immediately; callers click the tab they need.
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
- await page.waitForSelector('#wizardmodal');
+ await page.waitForSelector('#editmodal');
}
async function selectVendorFromTypeahead(page: any, vendorName: string) {
@@ -447,7 +447,7 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
await editButton.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
- await page.waitForSelector('#wizardmodal');
+ await page.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
}
@@ -472,7 +472,7 @@ test.describe('Transaction Edit Vendor Selection', () => {
// The server-rendered hidden input must carry the newly selected vendor id.
const hidden = page
.locator(
- 'div[hx-vals*="vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
+ 'div[hx-vals*="vendor-changed"] input[type="hidden"][name="transaction/vendor"]'
)
.first();
await expect(hidden).toHaveValue(vendorId.toString());
diff --git a/resources/templates/components/a-button.html b/resources/templates/components/a-button.html
new file mode 100644
index 00000000..c746750c
--- /dev/null
+++ b/resources/templates/components/a-button.html
@@ -0,0 +1 @@
+{% if indicator %}
diff --git a/resources/templates/components/validated-field.html b/resources/templates/components/validated-field.html
new file mode 100644
index 00000000..96806adf
--- /dev/null
+++ b/resources/templates/components/validated-field.html
@@ -0,0 +1,6 @@
+{# Field wrapper with label + always-present error
(the errors- variant of field-).
+ `classes` already folds group / has-error / caller class via hh/add-class; `attrs`
+ carries any pass-through div attributes (the per-row location cell hangs its hx-* /
+ x-dispatch swap wiring here); `body` is the pre-rendered inner control HTML;
+ `errors_str` is the comma-joined string errors (empty when none). #}
+
{% if label %}{% endif %}{{ body|safe }}
{{ errors_str }}
diff --git a/resources/templates/transaction-edit/account-totals.html b/resources/templates/transaction-edit/account-totals.html
new file mode 100644
index 00000000..cdc8f1b8
--- /dev/null
+++ b/resources/templates/transaction-edit/account-totals.html
@@ -0,0 +1,3 @@
+{# Totals live in their own swappable so an amount edit refreshes them with a
+ targeted swap, never replacing the input-bearing rows above (caret survives). #}
+{{ rows|safe }}
diff --git a/resources/templates/transaction-edit/approval-status.html b/resources/templates/transaction-edit/approval-status.html
new file mode 100644
index 00000000..58a7dc00
--- /dev/null
+++ b/resources/templates/transaction-edit/approval-status.html
@@ -0,0 +1 @@
+
{{ status_hidden|safe }}
{{ buttons|safe }}
diff --git a/resources/templates/transaction-edit/details-panel.html b/resources/templates/transaction-edit/details-panel.html
new file mode 100644
index 00000000..6a584217
--- /dev/null
+++ b/resources/templates/transaction-edit/details-panel.html
@@ -0,0 +1,2 @@
+{# Read-only transaction summary shown in the modal's left side panel. #}
+
Details
Amount
{{ amount }}
Date
{{ date }}
Bank Account
{{ bank_account }}
Post Date
{{ post_date }}
Description
{{ description_simple }}
Check Number
{{ check_number }}
Status
{{ status }}
Transaction Type
{{ type }}
diff --git a/resources/templates/transaction-edit/edit-form.html b/resources/templates/transaction-edit/edit-form.html
new file mode 100644
index 00000000..64e7a2b6
--- /dev/null
+++ b/resources/templates/transaction-edit/edit-form.html
@@ -0,0 +1,4 @@
+{# Top-level plain form. The entity id rides in a hidden field; all other state is the
+ live form, re-derived against the entity each request (no serialized snapshot, no
+ wizard step-params). #}
+
diff --git a/resources/templates/transaction-edit/edit-modal.html b/resources/templates/transaction-edit/edit-modal.html
new file mode 100644
index 00000000..734ee8d1
--- /dev/null
+++ b/resources/templates/transaction-edit/edit-modal.html
@@ -0,0 +1,4 @@
+{# Modal card chrome (header / optional side panel / body / footer). Single-step, so
+ no timeline, no back/next nav -- just the Done button in the footer. Enter triggers
+ the save button via $refs.next. #}
+
{{ head|safe }}
{% if side_panel %}
{{ side_panel|safe }}
{% endif %}
{{ body|safe }}
{{ footer|safe }}
diff --git a/resources/templates/transaction-edit/invoice-option.html b/resources/templates/transaction-edit/invoice-option.html
new file mode 100644
index 00000000..ac85e955
--- /dev/null
+++ b/resources/templates/transaction-edit/invoice-option.html
@@ -0,0 +1 @@
+
{{ number }}{{ vendor }}{{ date }}{{ amount }}
diff --git a/resources/templates/transaction-edit/linked-payment.html b/resources/templates/transaction-edit/linked-payment.html
new file mode 100644
index 00000000..7c695c62
--- /dev/null
+++ b/resources/templates/transaction-edit/linked-payment.html
@@ -0,0 +1 @@
+
Linked Payment{{ external_link|safe }}
Payment #
{{ number }}
Vendor
{{ vendor }}
Amount
{{ amount }}
Status
{{ status }}
Date
{{ date }}
{{ payment_id_hidden|safe }}
{{ unlink_button|safe }}
diff --git a/resources/templates/transaction-edit/links-body.html b/resources/templates/transaction-edit/links-body.html
new file mode 100644
index 00000000..5332da45
--- /dev/null
+++ b/resources/templates/transaction-edit/links-body.html
@@ -0,0 +1,3 @@
+{# The single step's body: memo + the activeForm tab switcher (link payment / unpaid /
+ autopay / rule / manual) + the five x-show panels. Fragments are pre-rendered. #}
+
{{ memo_field|safe }}
{{ action_hidden|safe }}{{ tabs|safe }}
{{ panel_payment|safe }}
{{ panel_unpaid|safe }}
{{ panel_autopay|safe }}
{{ panel_rule|safe }}
{{ panel_manual|safe }}
diff --git a/resources/templates/transaction-edit/manual-coding.html b/resources/templates/transaction-edit/manual-coding.html
new file mode 100644
index 00000000..c9315560
--- /dev/null
+++ b/resources/templates/transaction-edit/manual-coding.html
@@ -0,0 +1,3 @@
+{# Vendor field (a change repopulates the default account via a whole-form swap) + either
+ the simple single-row coding or the advanced account grid. #}
+
{{ mode_hidden|safe }}
{{ vendor_field|safe }}
{% if is_simple %}
{{ simple_mode|safe }}
{% else %}
{{ toggle_link|safe }}{{ accounts_field|safe }}
{% endif %}
diff --git a/resources/templates/transaction-edit/panel-empty.html b/resources/templates/transaction-edit/panel-empty.html
new file mode 100644
index 00000000..78e81f16
--- /dev/null
+++ b/resources/templates/transaction-edit/panel-empty.html
@@ -0,0 +1 @@
+
{{ message }}
diff --git a/resources/templates/transaction-edit/panel-list.html b/resources/templates/transaction-edit/panel-list.html
new file mode 100644
index 00000000..0bfe7219
--- /dev/null
+++ b/resources/templates/transaction-edit/panel-list.html
@@ -0,0 +1,3 @@
+{# Shared shell for the autopay/unpaid/rule match panels: heading + action hidden +
+ prompt label + a radio-card of options. #}
+
{{ heading }}
{{ action_hidden|safe }}
{{ radio|safe }}
diff --git a/resources/templates/transaction-edit/payment-matches.html b/resources/templates/transaction-edit/payment-matches.html
new file mode 100644
index 00000000..6d1ff5d5
--- /dev/null
+++ b/resources/templates/transaction-edit/payment-matches.html
@@ -0,0 +1,2 @@
+{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
+
{{ inner|safe }}
diff --git a/resources/templates/transaction-edit/rule-option.html b/resources/templates/transaction-edit/rule-option.html
new file mode 100644
index 00000000..1ecaaa23
--- /dev/null
+++ b/resources/templates/transaction-edit/rule-option.html
@@ -0,0 +1 @@
+
{{ note }}{{ description }}
diff --git a/resources/templates/transaction-edit/simple-mode.html b/resources/templates/transaction-edit/simple-mode.html
new file mode 100644
index 00000000..aa72b073
--- /dev/null
+++ b/resources/templates/transaction-edit/simple-mode.html
@@ -0,0 +1,4 @@
+{# Simple mode: a single account row (account typeahead + location select) rendered at a
+ fixed index 0, plus the link to switch to the advanced grid. Selecting the account
+ swaps just the location cell (#simple-account-location). #}
+
diff --git a/resources/templates/transaction-edit/transitioner.html b/resources/templates/transaction-edit/transitioner.html
new file mode 100644
index 00000000..f96ae53d
--- /dev/null
+++ b/resources/templates/transaction-edit/transitioner.html
@@ -0,0 +1,3 @@
+{# Wrapper the modal stack expects around the opened form (the wizard transition hooks
+ are gone -- there is only one step). #}
+
{{ body|safe }}
diff --git a/src/clj/auto_ap/ssr/components/selmer.clj b/src/clj/auto_ap/ssr/components/selmer.clj
new file mode 100644
index 00000000..1226145a
--- /dev/null
+++ b/src/clj/auto_ap/ssr/components/selmer.clj
@@ -0,0 +1,292 @@
+(ns auto-ap.ssr.components.selmer
+ "Selmer-rendered versions of the shared SSR components used by the Transaction Edit
+ modal (see .claude/skills/ssr-form-migration). Each wrapper assembles a plain-data
+ context and renders its own template under resources/templates/components/ via the
+ interop bridge -- the element structure lives entirely in the .html templates; the
+ only Clojure is data assembly. Dynamic HTMX/Alpine attributes (which vary per call
+ site) are serialized to an attribute string by `attrs->str` and injected with
+ {{ attrs|safe }}, so the templates stay free of per-attribute {% if %} ladders.
+
+ Reuses class logic from auto-ap.ssr.components.inputs so output matches the Hiccup
+ components byte-for-byte modulo Tailwind class ordering (verify by string-match +
+ e2e, never byte-parity -- see selmer-conventions.md)."
+ (:require
+ [auto-ap.ssr.components.buttons :as btn]
+ [auto-ap.ssr.components.inputs :as inputs]
+ [auto-ap.ssr.hiccup-helper :as hh]
+ [auto-ap.ssr.hx :as hx]
+ [auto-ap.ssr.selmer :as sel]
+ [clojure.string :as str]
+ [hiccup.util :as hu]))
+
+(defn- attr-name [k]
+ (if (keyword? k) (subs (str k) 1) (str k)))
+
+(defn attrs->str
+ "Serialize an attribute map to an HTML attribute string with a leading space, so it
+ concatenates after fixed template attributes: .
+ nil/false values are dropped, true renders a bare boolean attribute, everything else
+ renders name=\"escaped-value\". Mirrors how hiccup2 emits attributes."
+ [m]
+ (->> m
+ (keep (fn [[k v]]
+ (cond
+ (nil? v) nil
+ (false? v) nil
+ (true? v) (str " " (attr-name k))
+ :else (str " " (attr-name k) "=\""
+ (hu/escape-html (if (keyword? v) (name v) (str v)))
+ "\""))))
+ (apply str)))
+
+(defn render
+ "Render a component partial and trim outer whitespace (so {# comments #} and the
+ file's trailing newline don't leak into the embedding tree). Returns a raw-wrapped
+ string ready to drop into Hiccup or another Selmer context value."
+ [template ctx]
+ (sel/raw (str/trim (sel/render template ctx))))
+
+(defn- body->html
+ "Render child content (Hiccup vectors and/or raw Selmer fragments) to an HTML string."
+ [body]
+ (->> (if (sequential? body) body [body])
+ (remove nil?)
+ (map sel/hiccup->html)
+ (apply str)))
+
+;; --- leaf inputs -----------------------------------------------------------------
+
+(defn hidden [{:keys [name value] :as params}]
+ (render "templates/components/hidden.html"
+ {:attrs (attrs->str (merge {:name name}
+ (when (some? value) {:value value})
+ (dissoc params :name :value)))}))
+
+(defn text-input [{:keys [size] :as params}]
+ (let [attrs (-> params
+ (dissoc :error? :size)
+ (assoc :type "text" :autocomplete "off")
+ (update :class #(-> ""
+ (hh/add-class inputs/default-input-classes)
+ (hh/add-class %)))
+ (update :class #(str % (inputs/use-size size))))]
+ (render "templates/components/text-input.html" {:attrs (attrs->str attrs)})))
+
+(defn money-input [{:keys [size] :as params}]
+ (let [attrs (-> params
+ (dissoc :size)
+ (update :class (fnil hh/add-class "") inputs/default-input-classes)
+ (update :class hh/add-class "appearance-none text-right")
+ (update :class #(str % (inputs/use-size size)))
+ (assoc :type "number" :step "0.01"))]
+ (render "templates/components/money-input.html" {:attrs (attrs->str attrs)})))
+
+;; --- field wrapper ---------------------------------------------------------------
+
+(defn validated-field
+ "Selmer port of com/validated-field (the errors- variant of field-): label + body +
+ an always-present error
around already-rendered HTML fragments.
+ Plain-string composition (not Hiccup) -- the substantive markup lives in Selmer
+ component templates; this just nests their output."
+ [class & body]
+ (sel/raw (str "