Skip to content

Commit 94afbaa

Browse files
docs(block-editing): design spec for nesting-cursor UI enhancements
Design for three follow-on UI enhancements to the nesting cursor, plus a mechanical "locked tile" -> "outer block" rename sequenced first: - Geometry snapshot: size nest-move destinations from the block subtree's original rendered geometry (captured pre-swap), not the edit-distorted live DOM. Block-relative keys with a derived topBlockR0 (self-heal-robust). - Caret-aware nest-in: descend toward the caret's surface via line-anchoring against the untransformed AST; commit-if-dirty unifies with the reland path. - Mode-aware activation highlight: locked-mode hover/focus shows the outer block that activates; roving-tabindex made mode-aware to match. Captures both reflection passes, risks, and the TDD checklist. q2-preview iframe-side only; no Rust/WASM change.
1 parent e1abe89 commit 94afbaa

1 file changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# Nesting-cursor UI enhancements — geometry snapshot, caret-aware nest-in, mode-aware highlight
2+
3+
**Date:** 2026-06-14
4+
**Branch:** feature/block-editing-improvements (worktree `.worktrees/block-editing`)
5+
**Builds on:** `2026-06-11-block-editing-improvements.md` (the nesting-cursor / outer-block
6+
editor, Phases 1–3.5, all done). Same machinery; this plan is a follow-on set of UI
7+
enhancements + one mechanical rename, all q2-preview-only.
8+
**Status:** Design settled through two reflection passes (see *Reflections* below).
9+
All cross-feature ambiguities resolved with the user. TDD-first; ready to expand into the
10+
per-phase checklist.
11+
12+
## Overview
13+
14+
Three UI enhancements to the (already shipped) nesting cursor, plus a vocabulary rename
15+
sequenced first:
16+
17+
0. **Rename "locked tile" → "outer block"** — a pure mechanical rename, committed before any
18+
feature work, so the features are written in the final vocabulary.
19+
1. **Geometry snapshot** — fix wrong editing-surface sizes when moving the nesting cursor
20+
in/out. Capture the original rendered geometry of a multilevel block's whole subtree at
21+
activation (before the textarea swap) and size nest-move destinations from that snapshot
22+
instead of the edit-distorted live DOM.
23+
2. **Caret-aware nest-in** — nest-in descends toward the surface the **caret** is in, not a
24+
frozen "first clicked leaf". Implemented in line space against the untransformed AST.
25+
3. **Mode-aware activation indicators** — in locked mode the hover/focus highlight shows the
26+
**outer block** that will actually activate (not the deepest leaf); roving-tabindex becomes
27+
mode-aware to match.
28+
29+
All three are gated behind the existing `unlockNestingCursor` preference except Feature 3's
30+
locked-mode highlight change (which improves the *default* locked experience).
31+
32+
## Background / where the machinery lives
33+
34+
Citations are post-rename names; the rename mapping is in §0. Current tree (pre-rename) uses
35+
`lockedTiles.ts` / `resolveLockedTile` / etc.
36+
37+
- **Activation**`useBlockEditHover.tsx`: `activate(el)` (`:59`) resolves the click mode-aware
38+
(leaf in unlocked, `resolveOuterBlock` in locked), captures the identity triple
39+
(`captureEditTarget`), measures the box (`measureBlockBox`), seeds the draft, and calls
40+
`setEditTarget`. Hover outline is `box-shadow` set in `outlineElement` (`:49`) off
41+
`findEditTarget` = `closest('[data-block-pool-id]')` (the deepest leaf — **the Feature-3
42+
bug**). Roving-tabindex `onKeyDown` (`:227`) focuses `enumerateOuterBlocks` **unconditionally**
43+
(**the Feature-3 follow-on**).
44+
- **Edit substitution**`dispatchers.tsx`: `renderMeasuredEdit` (`:47`) replaces the *entire*
45+
matched subtree with a synthetic `<div id="q2-active-edit-region">` (ref =
46+
`activeEditRegionRef`) sized by `editTarget.contentHeight`/`boxStyle`. The nesting chord is
47+
handled in `EditTextarea`'s `onKeyDown` (`:281`) via `classifyNestingKey``requestNestingMove`.
48+
`LEFT_INSET_STRIPPED_TYPES` (`:60`) zeroes left margin/pad/border for list/quote types.
49+
- **Nest moves**`PreviewRoot.tsx`: `requestNestingMove(dir)` (`:780`) → `applyNestingRetarget`
50+
(`:742`); the latter reseeds the draft from the destination buffer and re-anchors `editTarget`,
51+
measuring the destination box via `outerBlockForAnchorR0(... {exactOnly:true})`
52+
`measureBlockBox`, **else keeping the current box** (`:752-753`, the stale fallback — **the
53+
Feature-1 bug**). `requestNestingSelect(r0,r1)` (`:794`) is the breadcrumb crumb-jump; both share
54+
`applyNestingRetarget`. `pendingCaretRef` is set `null` on nest moves (`:762`).
55+
- **Nesting surfaces**`nestingNav.ts`: `buildNestingSurfaces(sourceIndex)` (`:62`) → sorted
56+
`{r0,r1}[]`; `parentSurface` (`:103`) / `childSurfaceToward` (`:147`) navigate by **byte
57+
containment** (`childSurfaceToward` uses an **exclusive** end, `:165`). `buildAncestorPath` (`:307`)
58+
builds the breadcrumb path from the **live cursor** (`anchorR0/R1`). No geometry anywhere —
59+
surfaces are pure source coordinates.
60+
- **Breadcrumb**`BreadcrumbChip.tsx`: keys off `et.anchorR0/R1` (`:44`), repositions on
61+
`anchorR0/R1` change (`:40`) — already re-derives on every cursor move.
62+
- **Cross-surface move / reland**`PreviewRoot.tsx`: the Phase-2 arrow move uses
63+
`pendingLanding {intent, destLine, …}` (`:301`), commits when dirty, and re-resolves the
64+
destination post-render by **line** (`map.lineOf`, via `utils/byteLineMap.ts`). Nest moves
65+
(Feature 2, commit-if-dirty) **reuse this infrastructure** with a nest-specific resolution.
66+
- **Self-heal**`PreviewRoot.tsx:244-285`: on an external re-render, `findReanchorCandidate`
67+
re-anchors `anchorR0/R1` by content-verification (KEEP) or closes the editor (DROP). Keeps the
68+
box on KEEP.
69+
70+
---
71+
72+
## §0 — Rename: "locked tile" → "outer block"
73+
74+
The concept "which near-top block activates in locked mode" is currently called a **locked
75+
tile**. Rename to **outer block** (reads against the mode pair: *locked mode activates outer
76+
blocks; nesting mode descends to inner/leaf surfaces*). **One mechanical commit, first**, mirroring
77+
the prior depth→nesting rename (`16382ff2`). No behavior change.
78+
79+
| Current | New |
80+
| --- | --- |
81+
| `lockedTiles.ts` (+ `lockedTiles.integration.test.ts`, `lockedTiles-p2-3b.integration.test.ts`) | `outerBlocks.ts` (+ matching test files) |
82+
| `resolveLockedTile` | `resolveOuterBlock` |
83+
| `enumerateLockedTiles` | `enumerateOuterBlocks` |
84+
| `tileForAnchorR0` | `outerBlockForAnchorR0` |
85+
| `measureTileBox` *(mode-neutral)* | `measureBlockBox` |
86+
| `isVisibleTile` *(mode-neutral)* | `isVisibleBlock` |
87+
| "locked tile" in comments/docs, test vars (`tileA`, `destTile`, `mockTileRects`, …) | "outer block" / `outerBlockA`, `destBlock`, `mockBlockRects`, … |
88+
89+
`measureBlockBox` / `isVisibleBlock` drop "tile" rather than gain "outer" because they operate on
90+
*any* surface (nesting-mode leaves included). New symbols added by Features 1–2
91+
(`snapshotOuterBlockGeometry`, `childSurfaceTowardLine`, `enumerateNestingLeaves`) are authored
92+
with final names in the renamed files.
93+
94+
---
95+
96+
## §1 — Geometry snapshot for nesting-cursor moves
97+
98+
### The bug
99+
The nest-move destination box is measured from the **live DOM at move time**, but during an
100+
open editing session the active subtree is a synthetic textarea, so the destination surface is
101+
distorted (nest-out: parent contains a textarea, not the real child) or absent (nest-in: the
102+
child isn't rendered at all → `applyNestingRetarget` keeps the stale parent box). `PreviewRoot.tsx:751`
103+
even documents the gap ("real box fidelity for 'in'/jumps is P3.5").
104+
105+
### The fix
106+
Capture the original rendered geometry of the whole top-level block's subtree **at activation,
107+
before the swap**, keyed by source identity, and have nest moves size from that snapshot.
108+
109+
**Capture (`snapshotOuterBlockGeometry`)** — a new pure helper in `outerBlocks.ts`:
110+
1. From the opened element, climb to the outermost `[data-block-pool-id]` (the top-level block),
111+
reusing the chain-walk in `resolveOuterBlock`.
112+
2. If the subtree has **≤ 1** visible `[data-block-pool-id]` (a flat block) → return empty (no
113+
nesting possible; the single `measureBlockBox` at open already sizes it).
114+
3. Otherwise `querySelectorAll('[data-block-pool-id]')` within it, filter to visible, and
115+
`measureBlockBox` each. Key each by **block-relative r0** (`pool[id].r[0] − topBlockR0`); value
116+
= `{contentHeight, boxStyle}`.
117+
118+
**`topBlockR0` is derived, not stored** — the outermost `NestingSurface` containing the active
119+
range (climb `parentSurface` to `null`). Computed from the *same* source index at both capture and
120+
lookup, so block-relative keys are **shift-invariant under an insert-above** with no re-keying and
121+
no `editTarget` field. (Off-path-internal edits remain best-effort — documented.)
122+
123+
**Where capture runs** — at **every fresh editor-open**, synchronously *before* `setEditTargetRaw`
124+
(a post-open effect is too late; the children are already swapped out):
125+
- `activate()` (`useBlockEditHover.tsx`),
126+
- the Phase-2 reland opens (`PreviewRoot.tsx:388`, `:479`) and click-switch (`:387`).
127+
Only `applyNestingRetarget` **consumes** the snapshot; the Phase-2 opens still measure their own
128+
(rendered) outer-block destination live — `snapshot[thatBlock]` equals that live measure anyway.
129+
130+
**Consume**`applyNestingRetarget` replaces the live-DOM measure block (`:752-761`) with a
131+
snapshot lookup keyed by `next.r0 − topBlockR0(current)`. Fallback to today's best-effort if the
132+
key is missing (jsdom/no-layout, or an unrendered surface).
133+
134+
**Lifecycle**`editGeometryRef: Map<number, {contentHeight, boxStyle}>` on `PreviewRoot`. Written
135+
at each open; **kept** across self-heal KEEP (keys stay valid); **cleared** when `editTarget → null`
136+
(the `setEditTarget(null)` wrapper, `:191`).
137+
138+
**Gating** — only captured when `unlockNestingCursor` is on AND the block is multilevel. Locked
139+
default and flat blocks: zero cost.
140+
141+
**Validation insight:** `snapshot[surface]` is *by construction* identical to the box a direct
142+
click on that surface would produce (both measure the same element in the same pre-swap DOM). So
143+
"nest-in to item 3" reproduces "click item 3", which is already correct.
144+
145+
---
146+
147+
## §2 — Caret-aware nest-in
148+
149+
### Behavior
150+
Nest-in descends toward the surface the **live caret** is in (not the frozen `leafAnchorR0`). The
151+
live cursor + caret are the **single source of truth**; the breadcrumb re-derives from the cursor
152+
each render and stays consistent.
153+
154+
### Mechanism — line space end to end
155+
- **Caret → source line:** `selectionStart` → buffer line `Lb` (count `\n` before caret) →
156+
`sourceLine Ls = lineOf(cursorR0) + Lb`. This holds for **both** draft kinds: a verbatim slice
157+
trivially preserves lines, and a regenerated clean buffer strips `> `/indent **per line**, which
158+
preserves line count and order.
159+
- **Source line → child:** new pure `childSurfaceTowardLine(surfaces, cursorR0, cursorR1, Ls, map)`
160+
picks the direct child whose **line span** `[lineOf(r0), lineOf(r1)]` contains `Ls`. Line space
161+
sidesteps the `> `/indent prefix (which only shifts *columns*) and `childSurfaceToward`'s
162+
exclusive-end byte containment. Fallback (caret on a no-child line, e.g. a blockquote `>`
163+
separator): nearest direct child by line distance.
164+
- **Line-count mismatch → warn-and-proceed:** if `draft` line count ≠ the cursor node's source
165+
line count, `console.warn(siKey, draftLines, sourceLines)` and **still** use line-anchoring
166+
(better than collapsing to line 1; a real reflow regression stays visible).
167+
168+
### Caret reading — centralized
169+
`requestNestingMove('in')` reads the live textarea itself via
170+
`activeEditRegionRef.current?.querySelector('textarea')` (`selectionStart` survives the breadcrumb
171+
button click), so the keyboard chord (`dispatchers.tsx`) and the `` button
172+
(`BreadcrumbChip.tsx`) share one source of truth — no signature change.
173+
174+
### Round-trip caret placement (consistency)
175+
Reusing `pendingCaretRef` + `caretGeometry`:
176+
- **nest-in:** place the caret in the child's reseeded draft at `draftLine = Ls − lineOf(childR0)`.
177+
- **nest-out:** place the caret at the **exited child's** line in the parent draft, so an immediate
178+
nest-in returns to the same child (not the first).
179+
Without this, `pendingCaretRef = null` defaults the caret to the top and out-then-in jumps to the
180+
first child.
181+
182+
### `leafAnchorR0`
183+
Demoted to a pure fallback (no readable caret / programmatic). The P3.4 "crumb-jump preserves the
184+
original leaf" test (test 6) is **updated** to the caret model: after a crumb jump, the next
185+
nest-in follows the caret.
186+
187+
### Commit-if-dirty, then nest (unifies with the move/reland path)
188+
A nest move while the buffer is dirty must **not** discard edits (today `applyNestingRetarget`
189+
reseeds with no commit — a data-loss footgun, worsened by caret-aware nest-in). Mirror the Phase-2
190+
arrow-move:
191+
- **Unmodified** → synchronous hop: resolve via the snapshot box, no commit, no re-render.
192+
- **Modified** → commit (`SET_AST`), stash a **nest-variant `pendingLanding`** keyed by the caret's
193+
**source line** + direction, re-resolve the destination **post-render by line-containment** in
194+
the new source index, then open (live-measure + fresh snapshot). Round-trip caret line projects
195+
through the commit like `destLine`.
196+
- Caveat (carried over): committing at the list level reformats/renumbers the whole list (the
197+
existing wholesale-container-rewrite risk).
198+
199+
---
200+
201+
## §3 — Mode-aware activation indicators
202+
203+
### Hover / touch outline (the reported bug)
204+
The outline highlights the deepest leaf, but locked-mode click activates the **outer block**
205+
the highlight promises a granularity the click doesn't deliver. Make the outline use the same mode
206+
branch `activate()` and the click-switch already use:
207+
- **Locked:** `resolveOuterBlock(el)` — highlight the whole list/blockquote/coincident wrapper.
208+
- **Nesting:** unchanged — the leaf (`closest('[data-block-pool-id]')`).
209+
Two call sites: `onPointerMove` mouse hover (`:128`) and the touch branch of `onPointerDown` (`:178`).
210+
Dedupe by comparing the *resolved* element (and only re-resolve when the raw leaf changes, to keep
211+
`pointermove` cheap).
212+
213+
### Roving-tabindex (follow-on for consistency)
214+
`onKeyDown` (`:227`) focuses `enumerateOuterBlocks` unconditionally, so after the hover fix mouse
215+
and keyboard would disagree in nesting mode. Make it mode-aware:
216+
- **Locked:** `enumerateOuterBlocks` (today).
217+
- **Nesting:** new pure `enumerateNestingLeaves(host)` (visible `[data-block-pool-id]` with no
218+
pool-id descendant), so arrows focus/activate the same leaves mouse does.
219+
220+
---
221+
222+
## Reflections (resolved)
223+
224+
1. **Snapshot must capture at all fresh-open sites, synchronously pre-swap** — not just
225+
`activate()`. (§1 *Where capture runs*.)
226+
2. **`topBlockR0` derived, not stored** — simpler than store+re-anchor; shift-invariant keys. (§1.)
227+
3. **Caret→child in line space via a new helper**`childSurfaceToward`'s exclusive-end byte
228+
containment misfires on the prefix. (§2.)
229+
4. **Caret reading centralized in `requestNestingMove`.** (§2.)
230+
5. **Caret-driven nest-in vs. breadcrumb invariant** → caret wins; breadcrumb re-derives from the
231+
live cursor (already structural); P3.4 test updated. (§2.)
232+
6. **Roving-tabindex made mode-aware** to match Feature 3. (§3.)
233+
7. **Round-trip caret placement** is the concrete mechanism that makes in/out consistent. (§2.)
234+
8. **Nest-move-while-dirty discarded edits** → commit-if-dirty, unifying with the reland path. (§2.)
235+
9. **Snapshot == direct-click box by construction** — de-risks Feature 1. (§1.)
236+
10. **Snapshot ↔ nesting-surface alignment** (DOM pool-id set == source-index non-Opaque set) is an
237+
assumption — add a regression test (a nested list/blockquote).
238+
239+
## Risks / watch-items
240+
- **Snapshot capture cost**`getComputedStyle` + `getBoundingClientRect` per surface, once per
241+
activation, gated to unlock+multilevel. Fine now; watch on pathologically large blocks.
242+
- **Hover re-resolution cost**`resolveOuterBlock` per `pointermove` (coincidence rect-climb);
243+
deduped by raw-leaf change.
244+
- **Off-path-internal concurrent edit** — block-relative keys (and a re-anchored cursor) can be
245+
stale for surfaces after an in-block sibling edit; best-effort fallback. Bounded; documented.
246+
- **Wholesale container rewrite on a dirty list-level commit** — unchanged Phase-3 risk; snapshot
247+
never byte-asserts siblings.
248+
- **Line-count mismatch (reflow)** — warn-and-proceed; the warning surfaces a real regression.
249+
250+
## Verification
251+
- **jsdom/RTL:** snapshot capture + consume (mock `getBoundingClientRect` per surface, the existing
252+
`mockBlockRects` pattern); `childSurfaceTowardLine`; mode-aware outline + roving; commit-if-dirty
253+
nest reland; surface/pool alignment. Fail-on-revert on each behavior lever.
254+
- **Playwright (`hub-client/e2e`):** the real-pixel confirmation of the two reported cases — nested
255+
list, click sublist → nest-out covers full parent+child; click parent → nest-in sizes to one
256+
item; caret on item 3 → nest-in opens item 3; locked hover highlights the whole list. (jsdom has
257+
no layout; this is the genuine end-to-end tier, as in Phase 3.5.)
258+
- **Rust/WASM:** none — all three features are iframe-side TS. (The regenerated-buffer line-count
259+
property relies on the existing per-line `> `/indent strip; no writer change.)
260+
261+
## TDD checklist (to expand via writing-plans)
262+
- [ ] **§0 Rename** (mechanical, first commit) — `outerBlocks.ts` + symbol/test-var sweep; green
263+
build + suites; no behavior delta.
264+
- [ ] **§1 Geometry snapshot** — capture helper + gating + lifecycle; derive `topBlockR0`; consume
265+
in `applyNestingRetarget`; capture at all open sites; self-heal KEEP keeps keys valid; alignment
266+
test; Playwright pixel check.
267+
- [ ] **§2 Caret-aware nest-in**`childSurfaceTowardLine`; caret→source-line; centralized caret
268+
read; warn-and-proceed; round-trip caret placement; `leafAnchorR0` fallback + P3.4 test update;
269+
commit-if-dirty reland (nest-variant `pendingLanding`); Playwright caret check.
270+
- [ ] **§3 Mode-aware indicators** — mode-aware outline (mouse + touch); `enumerateNestingLeaves` +
271+
mode-aware roving; dedupe; Playwright locked-hover check.

0 commit comments

Comments
 (0)