|
| 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