From d3ef71e7b951035a0835352f431da38448808ee8 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Wed, 10 Jun 2026 21:39:48 -0400 Subject: [PATCH 01/28] feat(block-editing): keyboard a11y, touch gestures, and layout-stable edit wrapper Add roving-tabindex + ARIA keyboard navigation between blocks and touch-gesture suppression for the inline editor. Replace per-type edit substitution with a measure-and-set wrapper that reproduces each block's exact box (margins, padding, borders), so activating an editor no longer shifts the surrounding layout. --- ...editing-plan-2b-interaction-and-editing.md | 42 +- ...6-06-block-editing-plan-3-nested-blocks.md | 4 +- ...k-editing-plan-2c-keyboard-touch-polish.md | 412 +++++++++++++----- hub-client/changelog.md | 1 + hub-client/e2e/q2-preview-inline-edit.spec.ts | 212 +++++++++ .../src/services/applyNodeEdit.wasm.test.ts | 180 ++++++++ package-lock.json | 58 ++- ts-packages/preview-renderer/package.json | 3 +- .../src/q2-preview/PreviewContext.tsx | 33 +- .../src/q2-preview/blocks/BlockQuote.tsx | 2 +- .../src/q2-preview/blocks/BulletList.tsx | 2 +- .../src/q2-preview/blocks/CodeBlock.tsx | 13 +- .../src/q2-preview/blocks/DefinitionList.tsx | 2 +- .../src/q2-preview/blocks/Div.tsx | 5 +- .../src/q2-preview/blocks/Figure.tsx | 7 +- .../src/q2-preview/blocks/Header.tsx | 7 +- .../src/q2-preview/blocks/LineBlock.tsx | 2 +- .../src/q2-preview/blocks/OrderedList.tsx | 5 +- .../src/q2-preview/blocks/Para.tsx | 3 + .../src/q2-preview/blocks/RawBlock.tsx | 2 +- .../src/q2-preview/blocks/Table.tsx | 2 +- .../src/q2-preview/custom/Callout.tsx | 3 +- .../src/q2-preview/custom/FloatRefTarget.tsx | 2 +- .../src/q2-preview/custom/Proof.tsx | 3 +- .../src/q2-preview/custom/Theorem.tsx | 3 +- .../src/q2-preview/dispatchers.tsx | 75 +++- .../preview-renderer/src/q2-preview/entry.tsx | 24 +- .../useBlockEditHover.integration.test.tsx | 216 ++++++++- .../src/q2-preview/useBlockEditHover.tsx | 97 ++++- .../useEditableBlock.integration.test.tsx | 118 ++++- 30 files changed, 1353 insertions(+), 185 deletions(-) diff --git a/claude-notes/plans/2026-06-06-block-editing-plan-2b-interaction-and-editing.md b/claude-notes/plans/2026-06-06-block-editing-plan-2b-interaction-and-editing.md index 4b2a719fb..ec5814d84 100644 --- a/claude-notes/plans/2026-06-06-block-editing-plan-2b-interaction-and-editing.md +++ b/claude-notes/plans/2026-06-06-block-editing-plan-2b-interaction-and-editing.md @@ -3,7 +3,8 @@ **Date:** 2026-06-06 (revised 2026-06-08: built on Plan 2a's dual-node substrate; absorbed the former Plan 5 editability + Plan 6 render-component work; revised 2026-06-08b: two-channel API, discriminated payload, editTarget rect, test environments, Pass-2 exact deletions; -revised 2026-06-09: usePreviewEdit hook for render-component authors; boundary section rewrite; backend guard clarification; isEditTarget + setEditTarget type fixes; hostProps note) +revised 2026-06-09: usePreviewEdit hook for render-component authors; boundary section rewrite; backend guard clarification; isEditTarget + setEditTarget type fixes; hostProps note; +**post-execution accuracy note 2026-06-10:** `editTarget` gained `contentHeight` field (see P1); `useEditableBlock.tsx` was created then deleted in Plan 3, which moved all textarea logic to `Block`/`CustomBlock` dispatchers; `reachabilityClass` gate widened to `!== 'Opaque'` in Plan 3; LineBlock and Figure got `data-block-pool-id` in Plan 3.) **Branch:** feature/block-editing (worktree `.worktrees/block-editing`) **Spec:** `claude-notes/designs/2026-06-06-block-editing-design.md` **Phase:** 2b. Frontend (+ one Rust cleanup: remove `lookup_block` Pass-2). @@ -31,7 +32,11 @@ is unchanged (±1px). (Overflow scrolls internally; accepted.) **Sizing mechanism:** `useBlockEditHover` measures the activated element's `DOMRect` synchronously in the event handler (before state update) and stores it -in `editTarget.rect`. `useEditableBlock` reads `editTarget.rect` to size the textarea. +in `editTarget.rect` (plus a `contentHeight` field — `rect.height` minus padding +and border — so the textarea fills the content area exactly without reflow even +when the element has padding/border, e.g. Bootstrap `h2`). `useEditableBlock` +(later: the `Block` dispatcher — see Plan 3) reads `editTarget.rect` and +`editTarget.contentHeight` to size the textarea. **Test environments:** The reflow criterion (sibling top ±1px) requires real layout — it is a **Playwright** test in `q2-preview-spa/`. The sizing *logic* @@ -77,16 +82,23 @@ A node is editable iff `editable(node)` (Plan 2a's structural gate: the affordance keys off that. **Editable types** (all `TopLevel`): `Para`, `Header`, `CodeBlock`, `RawBlock`, `Table` (Tier-2), and **as a whole** `Div`(non-section), `BlockQuote`, `BulletList`, `OrderedList`, `DefinitionList` -(Tier-2). Container *contents* are gated off until Plan 3. **LineBlock excluded** -(parses as `Para`). **Custom-rendered nodes** (`Callout`/`Theorem`/`Proof`/ -`FloatRefTarget`) are editable-as-whole too: their `sourceNode` is a plain `Div` -(writer-covered), so the `custom/` components must also spread -`data-block-pool-id` and participate — this is the former Plan 5 "editability -wiring," now just part of 2b. +(Tier-2). Container *contents* are gated off until Plan 3. **Custom-rendered nodes** +(`Callout`/`Theorem`/`Proof`/`FloatRefTarget`) are editable-as-whole too: their +`sourceNode` is a plain `Div` (writer-covered), so the `custom/` components must +also spread `data-block-pool-id` and participate — this is the former Plan 5 +"editability wiring," now just part of 2b. + +**(Plan 3 post-execution update)** The gate was widened from `=== 'TopLevel'` to +`!== 'Opaque'` in Plan 3, unlocking `Descendable` blocks (nested content). `Figure` +and `LineBlock` also received `data-block-pool-id` in Plan 3 (Figure was omitted from +the editable-type list here; LineBlock was incorrectly listed as excluded — note that +LineBlock is unreachable in the pampa parser currently and would be editable if it +existed). All textarea substitution was centralized in `Block`/`CustomBlock` +dispatchers; `useEditableBlock.tsx` was created for Plan 2b then deleted in Plan 3. **Activation model change:** The per-block `onClick` handlers on Para and Header (Plan 1's seed mechanism) are **removed**. `useBlockEditHover` becomes the sole -activation path — it calls `setEditTarget({ poolId, rect })` on click/press/Enter. +activation path — it calls `setEditTarget({ poolId, rect, contentHeight })` on click/press/Enter. Editable components keep their `editTarget.poolId === myPoolId` check to decide when to render the textarea; only the trigger changes. @@ -116,12 +128,14 @@ commitTextEdit: (destinationSourceInfoJson: string, newText: string) => void; commitSubtreeEdit: (destinationSourceInfoJson: string, modifiedBlock: BlockNode) => void; ``` -**`editTarget` changes type** to carry the measured rect for P1 sizing: +**`editTarget` changes type** to carry the measured rect and content-area height for P1 sizing: ```typescript -editTarget?: { poolId: string | number; rect: DOMRect } | null; +editTarget?: { poolId: string | number; rect: DOMRect; contentHeight: number } | null; ``` +(`contentHeight` = `rect.height` minus computed padding and border; the textarea uses this so it fills the content area without reflow even on elements with padding or border.) + **`PreviewNodeEditPayload` becomes a discriminated union** on the wire (`types/diagnostic.ts`): @@ -296,7 +310,7 @@ P1 reflow Playwright test. See layer (no `onClick`-only path). - [x] `q2-preview/useBlockEditHover.tsx` — delegated pointer handler; outline; Pointer-Events progressive press; keyboard (Enter/Esc) activation; measures - `DOMRect` at activation and writes `setEditTarget({ poolId, rect })`. + `DOMRect` at activation and writes `setEditTarget({ poolId, rect, contentHeight })`. *Roving-tabindex arrow navigation, ARIA, and touch OS gesture suppression are in Plan 2c.* - [x] `PreviewDocument.tsx` — spread the hook's `hostProps` on the root host @@ -308,9 +322,9 @@ P1 reflow Playwright test. See - [x] **`PreviewContext`** — replace `commitEdit(poolId, newText)` with `commitTextEdit(destinationSourceInfoJson, newText)` and `commitSubtreeEdit(destinationSourceInfoJson, modifiedBlock: BlockNode)`; change - `editTarget` type to `{ poolId: string | number; rect: DOMRect } | null`; + `editTarget` type to `{ poolId: string | number; rect: DOMRect; contentHeight: number } | null`; change `setEditTarget` type to - `(target: { poolId: string | number; rect: DOMRect } | null) => void`. + `(target: { poolId: string | number; rect: DOMRect; contentHeight: number } | null) => void`. - [x] **`types/diagnostic.ts`** — replace `PreviewNodeEditPayload` with the `channel`-discriminated union (see "Two edit modalities" above). - [x] **`entry.tsx`** — update `commitTextEdit`/`commitSubtreeEdit` implementations diff --git a/claude-notes/plans/2026-06-06-block-editing-plan-3-nested-blocks.md b/claude-notes/plans/2026-06-06-block-editing-plan-3-nested-blocks.md index ab01af7d4..7636b1d94 100644 --- a/claude-notes/plans/2026-06-06-block-editing-plan-3-nested-blocks.md +++ b/claude-notes/plans/2026-06-06-block-editing-plan-3-nested-blocks.md @@ -351,7 +351,7 @@ below. known rough edges (hover ring on a `` inside `
  • `, span-in-li HTML). If the span approach causes layout problems, fall back to no affordance on Plain for v1 and document explicitly. -- [ ] **U1 (resolved — no work, just don't regress it).** The backend miss-guard +- [x] **U1 (resolved — no work, just don't regress it).** The backend miss-guard is `lookup_block` → `None` ⇒ no-op + original content (tested: `stale_ast_miss_noops_and_returns_original_content`, `apply_node_edit_noops_for_synthetic_target`, `..._inside_include`). It is *not* @@ -364,7 +364,7 @@ below. - [x] `cargo xtask verify --skip-hub-build` — Rust-only compile + tests green (exit code 0). - [x] From `hub-client/`: `npm run test:ci` — vitest suite: 556 unit + 66 integration tests pass; `useEditableBlock.integration.test.tsx` updated to test via `Block` dispatcher. - [x] From `hub-client/`: `npm run build:all` — TypeScript type-check + WASM + production bundle green. -- [ ] Dev server: verify the hover ring now appears on nested Descendable blocks +- [x] Dev server: verify the hover ring now appears on nested Descendable blocks (Para inside fenced div, list items, blockquote) — intended but a visible UX change. Then edit a paragraph inside a callout / fenced div end-to-end; confirm only that paragraph changed in the resulting `.qmd`. Also verify whole-block diff --git a/claude-notes/plans/2026-06-10-block-editing-plan-2c-keyboard-touch-polish.md b/claude-notes/plans/2026-06-10-block-editing-plan-2c-keyboard-touch-polish.md index 218ee98d5..79aa66ef0 100644 --- a/claude-notes/plans/2026-06-10-block-editing-plan-2c-keyboard-touch-polish.md +++ b/claude-notes/plans/2026-06-10-block-editing-plan-2c-keyboard-touch-polish.md @@ -1,16 +1,35 @@ # Block editing — Plan 2c: keyboard a11y, touch polish, Tier-2 tests **Date:** 2026-06-10 -**Branch:** feature/block-editing (worktree `.worktrees/block-editing`) -**Depends on:** Plans 2a, 2b, 3. +**Branch:** feature/block-editing-improvements (worktree `.worktrees/block-editing`) +**Depends on:** Plans 2a, 2b, 3 (all done). **Audit source:** 2026-06-10 post-2b audit — items that were either falsely checked off in 2b (`useBlockEditHover` keyboard/ARIA) or left unchecked and not yet filed anywhere (touch OS gesture suppression, Tier-2 WASM tests, P1 reflow -Playwright test). +Playwright test). Section 0 (margin regression) discovered during review. ## Overview -Three independent areas, roughly in priority order: +**Implementation order:** 0 → 1 → 2 → 3 → 4 (the plan's priority order). Sections 0, 1, +and 2 all touch `useBlockEditHover.tsx`; working through them in sequence avoids +merge conflicts within that file. Section 3 (WASM tests) is independent of the +TypeScript work and can run between any sections, but 3 after 2 keeps a clean +commit history. Section 4 (Playwright) requires Section 0 to be done first (the +structural margin guarantee it tests). + +Four areas, in priority order: + +0. **Edit-surface box fix (regression from Plan 3)** — Plan 3 moved textarea + substitution to the `Block`/`CustomBlock` dispatchers, which replaced the + original element with a bare `