docs: clarify cursors are fine; only faked positions are the smell
Reframe goal 2, the rationale (2.2), the render-function pattern (3.2), and scorecard heuristic 1 so the target is top-rooted cursors. Cursors stay; what we remove is faking a cursor to start deeper in the tree and the duplicate *-no-cursor* variants that fakery forces. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,17 @@ skill* that makes the next migration cheaper.
|
|||||||
## 1. Goals
|
## 1. Goals
|
||||||
|
|
||||||
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
||||||
fragment) over HTMX, instead of mutating the DOM in place. This removes the
|
fragment) over HTMX, using hx-select to choose elements, instead of mutating
|
||||||
class of bugs around stale state, lost focus/caret, and out-of-band patching.
|
the DOM in place. This removes the class of bugs around stale state, lost
|
||||||
2. **Make render functions pure.** A render function takes an explicit data map
|
focus/caret, and out-of-band patching.
|
||||||
and returns markup. No dynamic bindings, no "cursor" context, no duplicate
|
2. **Root cursors at the top; never fake their position.** Cursors are fine and
|
||||||
`*-no-cursor*` variants.
|
stay — a render function may take an explicit data map *or* a cursor. What we
|
||||||
|
remove is the practice of **faking a cursor to start deeper** in the tree to
|
||||||
|
satisfy a partial render, and the duplicate `*-no-cursor*` variants that
|
||||||
|
fakery forces. The target: a cursor always begins at the top level of what the
|
||||||
|
form consumes and walks down naturally from there. (Because the whole form is
|
||||||
|
re-rendered each time, there is no longer any reason to fake a deep starting
|
||||||
|
position.)
|
||||||
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
||||||
are single-step; they become plain forms. Genuine multi-step flows use a
|
are single-step; they become plain forms. Genuine multi-step flows use a
|
||||||
small data-driven engine instead of protocols + middleware stacking.
|
small data-driven engine instead of protocols + middleware stacking.
|
||||||
@@ -44,13 +50,17 @@ form** and letting the typed value ride along in the form is simpler and
|
|||||||
correct, *provided the input the user is typing in is never inside the region
|
correct, *provided the input the user is typing in is never inside the region
|
||||||
being swapped*.
|
being swapped*.
|
||||||
|
|
||||||
### 2.2 Cursor-based rendering forces duplicate functions
|
### 2.2 Faking cursor positions forces duplicate functions
|
||||||
Render code that reads from dynamic bindings (a "form cursor") is
|
A "form cursor" itself is fine. The pain comes from **faking the cursor's
|
||||||
context-dependent and hard to test, which has spawned duplicate render functions
|
starting position** — rebinding the dynamic root deeper in the tree so a deeply
|
||||||
— one that reads the cursor and one that takes plain params:
|
nested render function can run against a fragment. That fakery is fragile and
|
||||||
|
hard to follow, and it has spawned duplicate render functions: one that reads the
|
||||||
|
faked cursor and one that takes plain params for the cases where the fake can't
|
||||||
|
be set up.
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
;; SMELL: needs cursor context (dynamic bindings *form-data* / *current* / *prefix*)
|
;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
|
||||||
|
;; so it only works when *current*/*prefix* were rebound to point there first.
|
||||||
(defn account-row* [{:keys [value client-id]}]
|
(defn account-row* [{:keys [value client-id]}]
|
||||||
(com/data-grid-row
|
(com/data-grid-row
|
||||||
(fc/with-field :transaction-account/account
|
(fc/with-field :transaction-account/account
|
||||||
@@ -58,11 +68,15 @@ context-dependent and hard to test, which has spawned duplicate render functions
|
|||||||
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
||||||
...))
|
...))
|
||||||
|
|
||||||
;; SMELL: a second copy of the same markup, just to avoid the cursor
|
;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
|
||||||
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
||||||
...)
|
...)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Target:** the cursor starts at the top of the form's data and walks down
|
||||||
|
naturally; a row render either takes explicit row data or receives a cursor the
|
||||||
|
caller advanced step-by-step from the root — never one teleported to a deep node.
|
||||||
|
|
||||||
### 2.3 Single-step forms wear wizard costumes
|
### 2.3 Single-step forms wear wizard costumes
|
||||||
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
||||||
serialize an EDN snapshot with custom readers into hidden fields, and register
|
serialize an EDN snapshot with custom readers into hidden fields, and register
|
||||||
@@ -163,9 +177,11 @@ state:
|
|||||||
(assoc attrs :key (str id "--" current-value))
|
(assoc attrs :key (str id "--" current-value))
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 Pure render functions
|
### 3.2 Render functions: explicit data, or a top-rooted cursor
|
||||||
|
|
||||||
One function, explicit data in, markup out:
|
One function, data in, markup out. The data can arrive as a plain map or via a
|
||||||
|
cursor — **as long as the cursor was rooted at the top of the form and walked
|
||||||
|
down to here**, never faked to start at this depth.
|
||||||
|
|
||||||
```clojure
|
```clojure
|
||||||
;; GOOD: pure, works everywhere, testable without setup
|
;; GOOD: pure, works everywhere, testable without setup
|
||||||
@@ -180,8 +196,19 @@ One function, explicit data in, markup out:
|
|||||||
...))
|
...))
|
||||||
```
|
```
|
||||||
|
|
||||||
If a caller still has a cursor, give it a *thin* wrapper that adapts cursor →
|
```clojure
|
||||||
data and calls the pure function. Never duplicate the markup.
|
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
|
||||||
|
;; The top-level render walks the cursor; the row fn receives the dereferenced
|
||||||
|
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
|
||||||
|
(defn account-rows [accounts-cursor]
|
||||||
|
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
|
||||||
|
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule is about *where the cursor starts*, not whether you use one. If a caller
|
||||||
|
already holds a top-rooted cursor, advance it and hand the row data (or the
|
||||||
|
advanced cursor) to one render function. Never rebind the cursor to teleport to a
|
||||||
|
deep node, and never keep a second `*-no-cursor*` copy of the markup.
|
||||||
|
|
||||||
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
||||||
|
|
||||||
@@ -301,7 +328,7 @@ convention, e.g. `.claude/skills/testing-conventions/SKILL.md`).
|
|||||||
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
||||||
reference/
|
reference/
|
||||||
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening
|
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening
|
||||||
pure-render.md # §3.2 pure functions + thin cursor adapters
|
render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions
|
||||||
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
||||||
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
||||||
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
||||||
@@ -331,7 +358,7 @@ the touched modal.**
|
|||||||
|
|
||||||
| # | Heuristic | Measure | Target |
|
| # | Heuristic | Measure | Target |
|
||||||
|---|-----------|---------|--------|
|
|---|-----------|---------|--------|
|
||||||
| 1 | Form-cursor / dynamic-binding usage | `grep -cE 'fc/with-field|\*form-data\*|\*current\*|\*prefix\*|-no-cursor'` | → 0 |
|
| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) |
|
||||||
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
|
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
|
||||||
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
||||||
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
||||||
@@ -377,8 +404,9 @@ Modal phases below list only what is *specific* to that modal plus this loop.
|
|||||||
wizard (engine + server state). When in doubt, it's a form.
|
wizard (engine + server state). When in doubt, it's a form.
|
||||||
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
||||||
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
||||||
5. [ ] **Extract pure render functions** (kills cursor faking and `*-no-cursor*`
|
5. [ ] **Consolidate render functions** so they take explicit data or a
|
||||||
duplicates — heuristics 1, 2).
|
top-rooted cursor — remove faked cursor positions and `*-no-cursor*`
|
||||||
|
duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
|
||||||
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
||||||
(heuristics 5, 8).
|
(heuristics 5, 8).
|
||||||
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
||||||
@@ -423,7 +451,8 @@ acceptable prerequisite.)
|
|||||||
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
||||||
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
||||||
swaps as worked examples.
|
swaps as worked examples.
|
||||||
- [ ] Write `reference/pure-render.md` from §3.2.
|
- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a
|
||||||
|
top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates).
|
||||||
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
||||||
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
||||||
Phase 2."
|
Phase 2."
|
||||||
@@ -487,8 +516,8 @@ apply it cold." Single-step form currently wearing a wizard costume.
|
|||||||
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
||||||
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
||||||
- [ ] Feed the skill; append scorecard row.
|
- [ ] Feed the skill; append scorecard row.
|
||||||
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, cursor-use
|
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and
|
||||||
all down vs. baseline.
|
faked-cursor count all down vs. baseline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user