docs/plans/2026-05-07-slate-v2-core-caret-movement-word-insert-break-ralplan.md
Select v2-core-caret-movement-word-insert-break as the next issue-backed
lane.
The decision:
Fix the model caret contract before adding more public movement hooks.
insertBreak must publish the new-block caret after marked/fragmented leaves.
word movement must walk logical text across sibling leaves.
Browser repair can only mirror the model result.
This is the right next lane because #3964 and #3973 are both ready-now
core caret repros, both were deliberately excluded from the structural delete
lane, and both hit the same architectural pressure: Slate must project a logical
caret through fragmented text without trusting DOM selection magic.
Intent:
insertBreak.unit: 'word' movement works when a word spans multiple sibling text
leaves at the start of a block.Desired outcome:
#3964 and #3973.In scope:
.tmp/slate-v2/packages/slate/src/editor/insert-break.ts.tmp/slate-v2/packages/slate/src/transforms-selection/move.ts.tmp/slate-v2/packages/slate/src/editor/positions.ts.tmp/slate-v2/packages/slate/src/editor/after.ts.tmp/slate-v2/packages/slate/src/editor/before.ts.tmp/slate-v2/packages/slate/test/snapshot-contract.ts.tmp/slate-v2/packages/slate/test/query-contract.ts.tmp/slate-v2/packages/slate/test/transforms/move/both/unit-word*.tsx.tmp/slate-v2/packages/slate-react/src/editable/caret-engine.ts.tmp/slate-v2/playwright/integration/examples/richtext.test.tsNon-goals:
normalizePoint API for #4618.#4648 unless it falls out
of the exact #3973 fix.removeNodes({ at: range }) helper for #3891.at repair for #5412.#5080.#5129.#3841 without Firefox browser proof.Decision boundaries:
.tmp/slate-v2 code and tests.Fixes #3964 only after a package test proves Enter at the
end of marked text creates the new block and moves selection there.Fixes #3973 only after a package test proves
selection.move({ unit: 'word' }) advances from the start of a multi-leaf
word.Unresolved user-decision points:
Principles:
Top drivers:
#3964: Enter after a mark creates a new line but leaves the caret on the
original line.#3973: Transforms.move(..., { unit: 'word' }) fails when a document starts
with multiple text leaves and no leading spaces.Editor.insertBreak delegates to splitNodes({ always: true }) at
.tmp/slate-v2/packages/slate/src/editor/insert-break.ts:9.selection.move lowers to Editor.before / Editor.after at
.tmp/slate-v2/packages/slate/src/transforms-selection/move.ts:34..tmp/slate-v2/packages/slate/src/editor/positions.ts:581.Viable options:
Patch React keydown/DOM repair.
Editor.insertBreak and selection.move would still be
wrong.Add a public point-normalization hook.
Fix core logical projection and selection publication.
Copy VS Code's word classifier wholesale.
Chosen option:
#3964 and #3973 in packages/slate.Consequences:
#3499, #4357, and #4195 may become exact fixes if their repros collapse
to the same mark/Enter package contract.#3841, #5629, and #4648 stay related unless extra browser/punctuation
proof lands.| Dimension | Score | Evidence |
|---|---|---|
| Slate-close unopinionated DX | 0.94 | No new public API; fixes existing Editor.insertBreak, Editor.after, Editor.before, and selection.move. |
| Regression-proof testing strategy | 0.93 | Issue candidate map marks #3964 and #3973 ready-now; plan requires red package tests before patching. |
| Browser/runtime realism | 0.88 | Browser word movement already has a DOM/model sync row, but Firefox/mobile are explicitly non-claims. |
| Ecosystem evidence | 0.90 | Lexical, ProseMirror, Tiptap, and VS Code were checked for movement/split strategy. |
| Minimality | 0.94 | Rejects normalizePoint, punctuation policy, range-remove, fragment insert, reverse traversal, and replace-node expansion. |
| Execution readiness | 0.94 | Live source owners and target tests are named. |
Total: 0.92.
Current Slate v2 owner map:
Editor.insertBreak is a thin command wrapper around splitNodes({ always: true }) at .tmp/slate-v2/packages/slate/src/editor/insert-break.ts:9.selection.move resolves target points through Editor.before /
Editor.after at
.tmp/slate-v2/packages/slate/src/transforms-selection/move.ts:34.Editor.after and Editor.before iterate Editor.positions and skip
non-selectable elements at .tmp/slate-v2/packages/slate/src/editor/after.ts:14
and .tmp/slate-v2/packages/slate/src/editor/before.ts:14..tmp/slate-v2/packages/slate/src/editor/positions.ts:577.insertBreak selection at
.tmp/slate-v2/packages/slate/test/snapshot-contract.ts:1151..tmp/slate-v2/packages/slate/test/interfaces/Editor/positions/all/unit-word-inline-fragmentation.tsx:8.#3973
initial multi-leaf repro, at
.tmp/slate-v2/packages/slate/test/transforms/move/both/unit-word.tsx:7.tx.selection.move({ unit: 'word' }) at
.tmp/slate-v2/packages/slate-react/src/editable/caret-engine.ts:138..tmp/slate-v2/playwright/integration/examples/richtext.test.ts:2861.Gap:
insertBreak after a mark places selection in
the created block.Live gitcrawl status:
gitcrawl doctor --json is usable but GitHub API sync is unavailable because
no token is present.gitcrawl threads --numbers 3964,3973,3891,5080,5412,5129,3962,4618,1654 --include-closed --json ianstormtaylor/slate grounded the candidate list.gitcrawl neighbors --number 3964 links #3499, #4357, #4195, and
#3841.gitcrawl neighbors --number 3973 links #3841, #5629, #4648, and
broader DOM/caret noise.Target rows:
| Issue | Decision | Reason |
|---|---|---|
#3964 | target | Exact ready-now package repro for marked insertBreak caret placement. |
#3973 | target | Exact ready-now package repro for word movement across initial sibling leaves. |
Related rows:
| Issue | Decision | Reason |
|---|---|---|
#3499 | related | Mark + Enter + undo pressure; may be improved by #3964, exact undo claim needs history proof. |
#4357 | related | Same mark-end Enter symptom as #3964; can become fixed if the same package proof covers it. |
#4195 | related | Same inconsistent return-key caret family; can become fixed if the same repro collapses to #3964. |
#3841 | related | Word movement inside custom insertBreak, but exact thread is Firefox-specific. |
#5629 | related | Word navigation pressure, likely punctuation/DOM path; keep separate unless core word projection fails the same way. |
#4648 | not claimed | Punctuation definition request, not the same as multi-leaf projection. |
#4618 | not claimed | Public normalizePoint hook remains rejected in this lane. |
Excluded ready rows:
| Issue | Reason |
|---|---|
#3891 | Remove-range helper API needs separate design. |
#5412 | insertFragment({ at }) regression is a fragment insertion lane. |
#5080 | Reverse Editor.nodes traversal is a query API lane. |
#5129 | Replace-node convenience transform is API design, not caret movement. |
| System | Evidence | Mechanism | Slate target | Verdict |
|---|---|---|---|---|
| Lexical | ../lexical/packages/lexical-selection/src/range-selection.ts:503; ../lexical/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx:2234 | Word movement delegates to selection modification; paragraph insertion is tested across multiple text nodes. | Keep model movement centralized and add fragmented-text insertion proof. | steal tests, not DOM dependency |
| ProseMirror | ../prosemirror-commands/src/commands.ts:355; ../prosemirror-commands/test/test-commands.ts:332 | splitBlock maps transaction position and tests split selection shape. | Keep split selection publication transaction-owned. | agree |
| Tiptap | ../tiptap/packages/core/src/commands/splitBlock.ts:33 | Wraps ProseMirror split behavior with optional mark preservation. | Do not expose product-level keepMarks; fix raw selection first. | partial |
| VS Code | ../vscode/src/vs/editor/common/cursor/cursorWordOperations.ts:211 | Word movement has explicit classifier policy. | Consider a later punctuation policy for #5629/#4648; do not block #3973 on it. | defer |
Phase 1: red package tests.
#3964 package proof:
Editor.insertBreak(editor) inside editor.update;#3973 package proof:
editor.selection.move({ unit: 'word' });Phase 2: smallest core fix.
positions logical-offset mapping or before/after
iteration if the red test proves the target point exists but is skipped.splitNodes/selection publication only if insertBreak creates
the right nodes and publishes the wrong selection.Phase 3: related issue proof.
#3964 red test also covers #4357/#4195, update the exact fixed
claims.#3499 requires undo/marks beyond the package split, keep it Related and
add a history follow-up.#3973 uncovers punctuation-policy behavior, keep #5629/#4648
separate unless a focused test is added.Phase 4: browser mirror proof.
Phase 5: ledgers and PR reference.
issue-coverage-matrix.md, fork issue dossier, and
pr-description.md.Fixes only for exact issue-shaped proofs.Focused package proof:
cd .tmp/slate-v2
bun test ./packages/slate/test/snapshot-contract.ts ./packages/slate/test/query-contract.ts ./packages/slate/test/transforms/move/both/unit-word.tsx ./packages/slate/test/transforms/move/both/unit-word-reverse.tsx
bun --filter slate typecheck
If React/DOM changed:
cd .tmp/slate-v2
bun --filter slate-react test:vitest -- caret-engine editing-kernel
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --project=webkit --grep "word movement|insertBreak|selection synchronized" --workers=2 --retries=0
Closeout:
cd .tmp/slate-v2
bun lint:fix
cd ../plate-2
bun run completion-check
| Objection | Answer |
|---|---|
| "This is a browser bug; let native selection handle it." | No. The public Editor.insertBreak and selection.move contracts must be correct without React. Browser proof mirrors the model. |
"Just expose normalizePoint." | Too early. That makes apps patch a broken default and would become another fragile selection interception API. |
| "Word boundary behavior is subjective." | True for punctuation. Not true for a single word split across sibling leaves. #3973 is projection, not policy. |
"Tiptap exposes keepMarks; should Slate add it?" | No for raw core. The default split selection must be correct first; product mark behavior belongs in extension policy if needed. |
| "Why not include #3891/#5412/#5080/#5129 now?" | Because they are different owners. Mixing them would hide the caret bug under API sprawl. |
#3964 and #3973 from excluded structural-delete rows to the next
active target lane.#3499, #4357, #4195, #3841, #5629, #4648, and #4618 with
explicit claim boundaries.Ralph execution landed tests. Exact fixed claims now change for the cases with package proof:
Coverage updates made by this planning and execution pass:
#3964: Fixes, marked insertBreak publishes selection in the created
block.#3973: Fixes, word movement crosses initial sibling text leaves.#3499: Related, mark/Enter/undo cluster; exact history claim deferred.#4357: Fixes, exact same marked-end Enter focus repro as #3964.#4195: Related, same return-key caret family.#3841: Related, custom insertBreak/word movement with Firefox proof
requirement.#5629: Related, word navigation pressure outside this exact projection
repro.#4648: Not claimed, punctuation policy request.PR auto-close count increases by 3.
Execution proof:
.tmp/slate-v2/packages/slate/test/snapshot-contract.ts: insertBreak after marked text moves selection into the new block..tmp/slate-v2/packages/slate/test/transaction-contract.ts: moves word selection across initial sibling text leaves.bun test ./packages/slate/test/snapshot-contract.ts ./packages/slate/test/transaction-contract.ts
passed with 222 tests.bun --filter slate typecheck passed.bun lint:fix passed.Live gitcrawl ledger sync:
docs/slate-issues/gitcrawl-live-open-ledger.md remains generated live-field
inventory only; v2 claim state is synced through this plan,
docs/slate-v2/ledgers/issue-coverage-matrix.md,
docs/slate-v2/ledgers/fork-issue-dossier.md, and
docs/slate-v2/references/pr-description.md.clawsweeper: applied. Used gitcrawl thread/neighbors and local issue maps.tdd: applied through package behavior tests. Both issue-shaped tests passed
against current source, so no core patch was needed.performance: skipped. This is correctness-first; no hot-path data structure
change is planned.vercel-react-best-practices: skipped. React is browser proof only unless
package proof exposes a DOM repair issue.high-risk-deliberate-pass: applied through claim boundaries and exact
non-goals.Ralph execution complete.
This was proof closure, not implementation. The current core moves the caret correctly across split leaves and after marked Enter.