Skip to content

PT-4370: Settled positions across the public position API - #11

Merged
katherinejensen00 merged 351 commits into
mainfrom
pt-4370-settled-positions
Oct 21, 2026
Merged

katherinejensen00 merged 351 commits into
mainfrom
pt-4370-settled-positions

Conversation

@tjcouch-sil

@tjcouch-sil tjcouch-sil commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Summary

Makes every public position of the platform editor — getSelection, onSelectionChange, setSelection, setAnnotation, insertNote — address the SETTLED document (what getUsj() returns), and fixes the Standard-view coordinate bugs that mis-placed programmatic annotations with nothing pending at all.

Why review this

Part of the Standard-view epic (PT-4186). PT-4370 is its "settled positions" work item. A tester reported that programmatic annotations land in the wrong place "mostly related to text within markers"; the investigation found that bug is STATIC (below) and closed it together with the pending-state contract the work item was opened for.

Companion: paranext-core PR paranext/paranext-core#2823 (widens core's own copy of the jsonPath types, documents the contract, adds the e2e guard). Merge this PR first, then move platform-yalc; core CI stays red until platform-yalc carries these commits.

#13 (PT-4201 copy/paste) is merged into this branch: paranext/main merged at 04960aa5; the conflict resolutions are listed in the merge commit.

What changed

Static layer — the tester's bug (no pending state involved)

  • In editable marker mode a char span renders as [\nd glyph, "⍽LORD", \nd* glyph]: the NBSP separator is a PREFIX of the first text child. The exporter strips it; the shared logical content model counted it, so every USJ text offset inside a char marker was off by one in both directions. LogicalTextSegment now carries a lead and the model matches the exporter (libs/shared).
  • Four presentation-only node classes were counted as content INDEXES: AttributeRunNode wrappers, ImmutableNoteCallerNode, an expanded note's editable caller text, and every child of an editable ChapterNode. All skipped now.
  • An exporter-oracle property test (packages/platform/src/editor/positions/logicalModel.oracle.test.tsx) checks every offset of every text item in both directions against the real editor→USJ exporter over usj2Sa, in editable/visible/hidden modes and Standard view, with zero exclusions.
  • $getNodeFromLocation / $getLocationFromNode map every Standard-view glyph and display-run node exactly through one display-byte span model (nested +, optbreak //, char attribute runs, \va/\vp/\ca/\cp runs, milestone runs, note callers, chapter/verse own text). Bytes with no USJ representation snap LEFT (spec §4). The 2SA data-driven gap sets shrank 279 → 104 with zero regressions; Standard view has no resolution or round-trip gap left.

Dynamic layer — settled ⇄ live translation while a marker edit is pending

  • New packages/platform/src/editor/positions/: each pending settle scope's rebuilt nodes are materialized in a headless scratch Lexical editor and positions are carried across the re-tokenization with the engine's whitespace-tolerant fragment byte anchor, plus a top-level index correction for scopes that split/merge paragraphs. Identity fast path when nothing is pending. A position a real caret occupies, or that names something in the settled document, always crosses — including through a typed footnote literal and on a settling note's own glyphs. One rule, no caveats (round 4): every position API uses the settled document getUsj() returns at that moment. Outbound (getSelection, onSelectionChange) always reports a location for a real caret — undefined means only "no selection". Inbound (setSelection, setAnnotation, insertNote) refuses — logged — ONLY a location that names nothing in getUsj(). Any byte with no counterpart on the other side snaps LEFT, in both directions, each end of a range on its own; a position names the byte in front of which it sits and maps through whatever contains that byte on its own side, so in front of bytes the other side lacks it snaps and just past them it is exact (with |lemma="grace" pending → |grace, the settled value start lands before grace). There is no partial-pairing refusal and no stale-basis refusal any more: the live↔settled translation is ONE constructive byte aligner (markerEdit/usfmByteAlignment.utils.ts) shared by positions, the settle's mark carry and its caret restore, and a position basis the tree has moved on from is rebuilt (logged as an error), never refused.
  • Inbound (setSelection, setAnnotation, insertNote) translate in a getEditorState().read() before their update and hand LIVE-coordinate ranges to the untouched resolvers. Outbound getSelection translates; onSelectionChange defers past the commit (microtask, coalesced, cancelled on unmount) only when something is pending — synchronous otherwise.
  • ContentJsonPath / PropertyJsonPath widened to eight clauses (paranext-core's independent declaration widened identically). editor.model.ts documents the settled contract on every position-bearing member.

Behavior changes hosts will see

  • All positions are settled coordinates. Hosts never see live coordinates.
  • Outbound property paths use bracket notation ($.content[1]['marker'], not $.content[1].marker); inbound accepts both. paranext-core's only jsonPath parser is notation-agnostic and its own writer already emitted brackets.
  • Round-trips through snapped bytes (+, second /, |, =, ", inter-attribute space) are lossy by design and land on the documented representative.
  • A selection endpoint at a glyph boundary is reported as the adjacent TEXT location (end of the preceding text run), not as a marker location.
  • An annotation never puts a marker glyph inside a <mark>: a range across a closer (…GOD\nd* and) becomes a mark on each side of the glyph. As of Round 7 the glyph itself holds the annotation as display-byte state and is painted with the same classes plus display-annotation.
  • A caret at a paragraph's content start is reported as the paragraph's first text item at offset 0 (it used to be {jsonPath: <para>, offset: 1}).
  • A caret with no text beside it is reported as its one USFM location (follow-up 45): in front of a marker → that marker's location; inside an empty paragraph → ['marker'] at the marker's length; the end of a paragraph ending in a note/char/verse → the last token's closing-marker/number offset at its length; the document end → one past the final newline on the last token (e.g. propertyOffset: 2 on an empty trailing \b). The editor no longer emits {jsonPath: <container>, offset} or {jsonPath: "$", offset}; it still accepts them inbound, resolving each as the canonical location of the gap it names.
  • insertNote(…, explicitRange) now moves the caret to the range it was given ($insertNoteWithSelect calls $setSelection), and on commit focuses/scrolls the editor. A host calling it from a dialog will lose focus to the editor.

Fixed in this PR (follow-ups, 2026-09-15)

  1. A Tier-2 settle discarded TypedMarkNode annotations inside the rebuilt paragraph or note content. Annotations are now carried across the settle by byte anchor (marks re-wrapped over the same content bytes; the fixed-point signature treats marks as transparent; a mark starting on a preserved node keeps it; the caret is not moved by the re-wrap; a note-content mark no longer fires the host's "destroyed" callback). Refuses rather than guesses when a mark's own bytes became a glyph or its anchors do not resolve. Standard view only. — 01e61166, d3cd1e97, 78636d94

  2. insertNote(marker, caller, selection) ignored selection for WHERE the note goes. $insertNoteWithSelect now makes the passed range the live selection before inserting, so a settled location is honoured for placement as well as content. All views (bug fix; no new behavior). — 34a36fe4

  3. The Standard-view exporter stripped a leading NBSP from ANY char-span text child, positionally broader than the model's glyph-adjacent rule. It now mirrors $charSeparatorPrefixLength exactly (serialized twin precedesOpeningCharGlyph). Note: the earlier "an authored ~ after a nested closer is eaten" framing did not reproduce — load and paste both convert an authored NBSP to a display ~ before any strip check — so this is an exporter↔model invariant alignment, not a data-loss fix. — 2f646c68

  4. The exporter dropped note content that merely EQUALLED the caller string. It now drops only the caller-slot text node (serialized twin of $noteEditableCallerNode). Every view that renders an editable caller. — 1d640619

  5. The preserved-run crossing REFUSED the whole scope when the live and scratch sentinel lists diverged (a husk in a pending paragraph refused every position in it). SettleScopePlan now carries an explicit live↔scratch run correspondence (sentinelMap), a settled-away run's placeholder byte is cut from the live fragment so byte anchors line up, and refusal is per position. Standard view only. — 010e7e68, 8c6458ed

  6. EditorRef.setSelection did not report the selection it placed when both endpoints landed inside text nodes: Lexical marks its own DOM-selection update and then skips SELECTION_CHANGE_COMMAND for the resulting selectionchange when neither endpoint is at a text boundary, so onSelectionChange (and the host's cached selection) stayed stale. setSelection now reports explicitly, once. Every view. — d5a6098a

  7. Committed dist rebuilt after the fixes above. — caa7ff92

Fixed in this PR (follow-ups, 2026-09-16)

  1. An annotation mark over a note's editable caller fabricated the caller into saved USJ content. Both caller-slot finders ($noteEditableCallerNode live, noteCallerSlotNode serialized) required the child at the caller position to BE a plain text node, and a TypedMarkNode is an ElementNode — so a marked caller anchored nothing, the identity-based drop had nothing to drop, and the caller's display bytes were spliced into the note's content, returning as a second fabricated caller on the next load and compounding every cycle. Both finders now look through annotation marks, and the exporter threads the anchored slot into its mark recursion so the drop reaches a node nested inside one. Marks were already transparent to every other USJ-facing view of the tree; the caller slot was the one place that was not. Every view that renders an editable caller. — f6e32568
  2. Four position defects in the settled-position translation, all of them in refusal paths the suites never exercised. New fixture (settledPositions.nestedScopes.test.tsx): a note settling inside its own settling paragraph, with an emptied optbreak husk in the paragraph AND in the note, so the two documents disagree about the note's index and about the note's own content indexes. — 0fd42572, dist 3ec08665
    • A note settling inside a settling paragraph reported at the note's LIVE index: $settledScopePath restated only the top-level index, but the paragraph's rebuild re-indexes its content around the note. It now asks the enclosing scope where the note landed, through the same preserved-run correspondence every other position in that scope crosses by, and refuses when that cannot be determined.
    • A husk-only paragraph scope carried the pending note: $planForHuskOnlyPara omitted the notes pass $planForParas runs, so a settled path into the note resolved against a paragraph holding the note's pre-settle children — a confident wrong live node, not a refusal.
    • $livePointInPreservedRun fell through to a child walk when a co-settling note had no byte correspondence, spelling a SETTLED child path against LIVE children. It refuses instead, which is what the comment above it already claimed.
    • An element boundary immediately after a preserved node could never be anchored: the boundary anchors at the end of the span before it, and that span is the preserved node's one-byte sentinel, which spanFor refuses. The end of a sentinel is exactly what this boundary means, so the boundary case now anchors from the span it already holds. A boundary in FRONT of a sentinel still refuses.

Fixed in this PR (follow-ups, 2026-09-18)

  1. Positions past a Standard-view space run were off by the spaces the run lost. Standard view shows each space of a typed run of two or more as an NBSP (so it stays visible) and serialization collapses the run to one space, but the logical content model counted every displayed character — so after a run, with nothing pending, setAnnotation/setSelection landed that many characters early and getSelection/onSelectionChange reported that many too high. The model now drops a run's extra spaces exactly where serialization does, from one shared definition (libs/shared/src/nodes/usj/spaceRuns.utils.ts; the serializer's normalizeSpaceRuns is replaced by it). A point inside a run reports the character after the kept space; a settled offset resolves to the live character it names. Collapse is view-dependent, so the position layer now takes the editor's view options explicitly ($getLocationFromNode, $getNodeFromLocation, $getRangeFromUsjSelection, $getUsjSelectionFromEditor, and the model's measuring functions; OnSelectionChangePlugin/AnnotationPlugin gain a viewOptions prop). The exporter oracle gains a space-run corpus in every mode and loses its waiver. Standard view only; the platform-editor's public API report is unchanged. — 7793539a, docs 25f99782, dist 3ef682ec

Fixed in this PR (review follow-ups, 2026-09-21)

Closes the open items of the review summary on paranext-core PR #2823.

  1. A settled top-level index past the settled document's end resolved onto a live paragraph. settledToLiveTopIndex fell back to the identity for an index its map did not cover; while a pending chapter region contracts the document (a \ca span folding onto its chapter), the live tree has an item at that index and the host's out-of-range path landed on the last paragraph. It now refuses. — 587e2dac
  2. The settle-plan cache reused a stale plan (pre-existing bug 3 below). The plan signature now includes every node key in the scope, so annotating inside a pending scope rebuilds the plan: a paragraph pending on an emptied optbreak husk, annotated through setAnnotation, used to resolve a later settled offset to offset 8 of a one-character node. The plan builders also apply the read-only settle's note-marker rename pass, so a scope's scratch tree carries the note's settled marker (fidelity only: no position read it). — 5eee8405
  3. An attribute run's | snapped to text AFTER it. $precedingTextLocation returned the end of the parent's last text item; it now takes the last text segment before the run (text typed after a char span's |lemma run, or a periph whose |id run precedes its direct text). — e0b9b2b6
  4. Refusal-path suite (settledPositions.refusals.test.tsx, pre-existing bug 2 below): every refusal a real pending state can produce, inbound and outbound — a location the settled document does not have, a scope whose documents cannot be paired (a typed note literal), a caret on a settling note's own glyph, a stale basis, a pend with nothing to rebuild (identity, not refusal) — and the public methods (setAnnotation, setSelection, insertNote, getSelection each log the refusal and change nothing). What stays unreached is by construction. — baead229
  5. Exporter oracle: an annotation-mark corpus (marks around a separator-bearing span text, an opener and its text, an opener alone, and a split space run; verified to fail when the exporter loses a mark's preceding sibling), and a check that every opening glyph inside a char span is one $charGlyphNestedValue recognizes — the premise precedesOpeningCharGlyph rests on. — e1f33a5c
  6. settledPositions.utils.ts imports the engine's FRAGMENT_WS instead of restating it. — 4783d20d; dist 5dcba95f
  7. Positions after content before the first paragraph named the wrong USJ index (pre-existing bug 6 below). The root's ImpliedParaNode, which the exporter splices into the root, is now transparent to the position layer exactly where annotation marks are: its children are root items, it is never a logical parent, an element point on it is a root boundary, and the index→point inverse ($getElementPointFromLogicalIndex, replacing $getElementOffsetFromLogicalIndex) puts a boundary among its items inside it. Only root-level implied paragraphs are transparent, matching the exporter. Model, location, exporter-oracle (every mode) and editor rows, each failing without the change. Every view. — b531a522
  8. A caret left in an emptied optbreak husk reported nothing. Deleting // leaves the caret in the husk, which the settled document has no node for, so getSelection returned undefined (and logged a refusal) until the caret moved. It now reports where the husk stood — the join of the text on either side — through the same byte anchor the rest of the scope crosses by. Standard view only. — 58741a4d
  9. Import cycles in libs/shared/src/nodes (pre-existing bug 8 below, plus the two older cycles through MarkerNode and ImmutableChapterNode): the pure helpers the back edges needed move into leaf modules (markerText.utils.ts, logicalParent.utils.ts), re-exported from node.utils, so the package surface is unchanged and no module under nodes/ is on a cycle. — 62cf8de9; dist 89a5508a

Fixed in this PR (code-review follow-ups, 2026-09-22)

A /code-review pass over both PRs; it found nothing in core. Each fix below comes with a test row that fails without it.

  1. The position layer did not recognize a note's caller through an annotation mark. Follow-up 8 made the caller-slot finders mark-transparent, but selection.utils.ts's display-byte helpers were not: $displayBytesOf required the caller's raw parent to be the note, and $displayByteCarriers matched only the note's direct children. With a mark on the caller, a caret in it was reported as {jsonPath: <note>, offset: 2} rather than <note>['caller'], and a <note>['caller'] location resolved to nothing. Every view that renders an editable caller; not reachable from a shipping paranext-core view. — 2fdf6023
  2. A boundary at the start of an element inside a pending scope resolved to the scope's start. $anchorForPoint fell back to the fragment start whenever none of the element's own children preceded the boundary, which is right only for the element that opens the fragment. A settled {jsonPath: <char span>, offset: 0} in a pending paragraph resolved to the paragraph's \p glyph instead of the live \nd literal, and the start of the second paragraph a pending split creates resolved to the first one's start; outbound shared the defect. It now anchors on the element's first byte, or just past the byte before the element when the element opens with a preserved node or holds no bytes. Standard view only. — 696d5ff3
  3. A Tier-2 settle dropped an annotation holding nothing but a preserved node. Both ends of such a mark anchor just past the run's placeholder byte, so its range resolved collapsed and was refused — silently, since a spliced-away mark fires no "destroyed" callback. A translator comment on exactly a footnote (or a verse number) lost its highlight the next time a marker settled in its paragraph. The run is now wrapped in a fresh mark by node identity, since the splice carries the original preserved nodes across. Standard view only. — fb9eafab; dist 6511c306

Checked and not changed: getSelection reading the pending state before its flush (every pend is written by a transform inside the synchronous update, so the ledger is already current); a settling note nested inside another preserved member (near-theoretical); the read-only settle carrying no comment marks (only Marginal's CommentPlugin uses that mark type).

Fixed in this PR (code-review follow-ups, round 2, 2026-09-22)

A second /code-review pass over both PRs (16 findings, each verified with a failing repro before any fix). Every fix below comes with test rows that fail without it; items marked pre-existing reproduce on the merge-base too but sit in code this PR's guarantees depend on.

  1. An annotation that ran out of a char span wedged the editor. $wrapSelectionInTypedMarkNode moved marker glyphs into the mark, so a range from inside \nd GOD\nd* past its closer took \nd*; the char span then read as having lost its closer, the Tier-2 settle's annotation carry re-wrapped the same range and took \nd* again, and Lexical threw "transforms are endlessly triggering additional transforms". Every later edit was then silently dropped. The same move let a range starting at a collapsed note's caller wrap \f* alone (the settle deleted the footnote) and one starting on \f dissolve the note. A mark now ends at a glyph and later content starts a new one, so no glyph is ever inside a mark. Every view. — dea0ea55

  2. A note's \f* (and \wj* after a nested \+nd*) was attributed to the element before it. $glyphOwner's previous-sibling rule reported a caret in \f* as the last \ft's closing marker, and resolved {note, closingMarkerOffset: 0} to the END of \f* — so typing over a selection ending there deleted the footnote. The rule now applies only to the loose read-only glyphs markerMode "visible" renders after a milestone. Every view. — d5ddf5d0

  3. An attribute run's | after a nested span snapped past it. \w \+nd LORD\+nd*|strong="H3068"\w* reported the | at the start of \w; it now takes the byte immediately before the run (the end of \+nd*), which also covers an opaque unknown block's |. Supersedes follow-up 13's text-item scan. Standard view. — d5ddf5d0

  4. A collapsed note's caller value did not resolve past its first byte. The caller decorator measured 0 bytes, so {caller, propertyOffset: 1} fell back to the hidden \f glyph (a range over the caller then split \f and deleted the note) — or to nothing in the Markers views. It now measures the caller value; the two data-driven gap rows for it are gone. Every view. — d5ddf5d0

  5. Pre-existing. A caret at a paragraph's content start was reported as the paragraph's end. Standard view keeps that caret at the end of the space after the \p glyph, which is not USJ content, and the fallback reported it as {jsonPath: <para>, offset: 1} — a text location on an element path, which resolves to the paragraph's last child. Hosts restoring a lost selection (restoreSelectionIfLost) moved the caret to the paragraph end, and a settled range at a pending paragraph's content start translated onto the wrong characters. A point in presentation-only text now reports the point beside it. Standard view. — d5ddf5d0

  6. Typing over a selection that ended where a footnote's text ends deleted the text after the note. Core's USFM→USJ conversion ends such a range at {note, closingMarkerOffset: 0} when the last char span has no closer (\f + \ft text\f*, the usual shape); that resolved past the presentation-only space in front of \f*, and deleting that space made the settle drop the paragraph's text after the note. A non-collapsed range ending at a closing marker's offset 0 now ends with the content before such text. Standard view. — 732d4456

  7. A note-content settle carried only its first annotation correctly. The carry and caret restore re-derived spans from the parsed newNodes, which a preserved run's placement and each re-wrap split out from under them: a second annotation landed on the wrong bytes, one after a preserved span was dropped, and the caret after a preserved span landed in the wrong text. Both now re-read the note's content with the capture's own walk. Standard view (expanded notes: the footnote editor and unclosed notes). — dc422433

  8. A mark beginning with a whole char span was dropped on every settle of its paragraph (its start anchored on the span's opening glyph, which the carry refuses). It now anchors on the first content byte. Standard view. — dc422433

  9. Pre-existing (since #439). The first edit after setSelection never reached the host. setSelection tagged its update SELECTION_CHANGE_TAG, and Lexical clears an update's tags only when its commit dirties a node — so after a selection-only placement inside a text run, the tag rode on the user's next keystroke and DeltaOnChangePlugin skipped it: no onUsjChange, no save scheduled, and a stale cached getUsj(). The tag is gone (a selection-only update emits no change anyway). Every view. — c58e6501

  10. A read-only editor never reported a setSelection to a text boundary or element point (Lexical writes no DOM selection for a non-editable editor, so no selectionchange follows); it now always dispatches the report. Every view. — c58e6501

  11. The settle-plan cache survived a stylesheet change, so a stylesheet arriving while a paragraph was pending translated positions by the old stylesheet's settle (a settled range landed in the wrong paragraph). The cache is cleared when the marker lookup changes. Standard view. — c58e6501

  12. getSelection read the pending state before flushing queued updates, so in the same tick as an undo (whose commit re-pends a paragraph) it reported live coordinates. It now flushes first. Standard view. — c58e6501; dist 728650f4

  13. getUsj() returned the pre-settle document after an annotation settled a pending paragraph. Once nothing is pending, the settled read hands back a cached serialization that only handleChange refreshes — and DeltaOnChangePlugin never calls it for a commit carrying a blackListedChangeTags tag. setAnnotation over a pending paragraph's own text splits it, and the marker-edit engine settles the paragraph inside the annotation's own update: afterwards getUsj() returned the typed literal as text (p:["plain \\q1 body"]) instead of the paragraphs on screen, until the next local edit — and a host that re-reads getUsj() when its save debounce fires would save that. Such a commit now marks the cache stale and the next settled read re-serializes. No change notification: the settled document the host was told about did not change. Every view. — 13553d55

  14. A caret-host repair tagged a commit it moved no node in. Lexical clears an update's tags only when that commit dirties a node, so a CURSOR_CHANGE_TAG on a repair that only moved the caret (adopting a host already at the boundary) would ride along on the user's next commit and keep that edit out of the host's USJ-change handler — the same leak follow-up 31 fixed in setSelection. Latent: no arrival I could drive reaches the adopt-only branch. The repair now reports whether it changed a node, and both callers tag only when it did. Every view. — 280b8bad; dist 58a00f78

Checked and not changed: a carry that fails fires no "destroyed" (no in-tree onRemove consumer, and deleting annotated text already fires none); a scope whose scratch rebuild will not parse (unreachable — the scratch registers the live editor's nodes, and a rebuild it could not parse would crash the real settle first); inbound translation in the same tick as an undo (host calls arrive as separate tasks); a \cat run and the caller slot with a mark on an expanded note's caller (Marginal's CommentPlugin only, or a host range starting on an unclosed note's caller, which nothing produces); a mark starting at a chapter's marker location (nothing produces one); $charSeparatorPrefixLength vs the exporter's strip rule (diverges only on a hand-built tree).

Fixed in this PR (code-review follow-ups, round 3, 2026-09-23)

A third /code-review pass over both PRs, then the owner's rulings on the open items. Every fix has test rows that fail without it.

  1. Stack overflow on every selection change in an empty char span. \w |lemma="x"\w*'s NBSP placeholder deferred forward to the attribute run, whose | snapped back to the placeholder. — 369faf00

  2. A content-less annotation mark reported the end of its paragraph instead of the boundary in front of it. — 0179fe7b

  3. A stale settled cache could reach the host (the pending path's fallback ignored the staleness flag, and the flag cleared before a re-serialize that could fail). — dd8b0eea

  4. Top-level indexes were off after the root's implied paragraph. A chapter region holding a typed \ca literal in an implied paragraph counted that paragraph's children as extra settled items, and an emptied optbreak in the root implied paragraph got no plan at all. — 495ba860

  5. getUsj() while an edit was pending dropped comment marks the real settle keeps (a comment is a milestone pair in USJ). The read-only settle now replays the mutating rebuild's own mark capture/restore in a scratch editor, so both agree by construction; the live chapter rebuild, which carried no marks, now carries them too. — e477ddd4

  6. A note settling inside its own settling paragraph resolved positions without the checks every other scope applies. — 3a1986e0

  7. Tests for behavior nothing exercised: deferred-report supersession, setSelection while pending, the last-seen-caret fallback, the caret-host tag guard. — 756c6954

  8. Pre-existing. The first edit after any navigation never reached onUsjChange. Verse navigation and the note-shell guard tagged caret-only updates CURSOR_CHANGE_TAG; Lexical keeps a tag on a commit that changes no node, so it rode on the user's next edit and the host never scheduled a save for it (one edit, then leaving the chapter, was lost). Those moves now dispatch APP_PLACED_CARET_COMMAND, which MarkerEditPlugin reads exactly as it read the tag. Every view. — a3ff2ebd

  9. Text-less carets were reported in an off-contract shape (offset on a container, or on $). They now report their one USFM location per the rule in the location model docs (see "Behavior changes"), derived from paranext-core's UsjReaderWriter; pending↔settled translation carries root indexes and the document end. Every view. — edd70f8c, 083ffaa0, docs ef58d197

  10. Positions refused through a typed footnote literal and on a settling note's own glyphs. Run pairing now accepts a note that exists only on the settled side; positions before, inside and after the literal cross both ways, and a note's marker/caller/closer map directly. — b13d3d47

  11. Annotations after or inside a typed footnote literal were lost or moved when its paragraph settled. The carry's byte anchors were captured over the literal's bytes and resolved over the rebuilt note's one placeholder byte, so a mark after the literal was dropped and one inside it could land on the wrong text. The carry now uses the same literal alignment as follow-up 46 (shared in markerEdit/settledOnlyRuns.utils.ts): marks before, after and spanning the literal keep their text, a mark inside the literal's note content lands on the same content in the new note, and anything the settle re-spells (a folded \cat, the note's own glyphs and caller) is dropped, never moved. Both settles agree. — 59586ef3

  12. Pre-existing. getUsj() while an emptied optbreak was pending moved every comment after it one character early (the read-only settle spliced the husk out of its output but captured marks over bytes that still held its placeholder). — 855028ff

  13. Pre-existing. Two separate comments in one paragraph exported an extra zmsc-e for the first, just before the second began. Fixed in the platform and scribe adaptors. — f6530a35

  14. A caret on a typed \cat or \fig literal's bytes, or anywhere the translation had no answer, reported undefined — the same value as "no selection". A typed literal's settled spelling now includes a figure's read-only attribute bytes and a note's folded \cat x\cat*, so those bytes map exactly to their attribute locations, both ways (values from UsjReaderWriter); two re-spelled literals in one scope pair up to the first; and anything still untranslatable snaps LEFT to the nearest position that translates. — 34664eab, docs 4dd73f0b

  15. Pre-existing. A figure's src bytes were reported as keyName: "src" / ['src'], which name nothing in USJ (the attribute is file). Every view, nothing pending needed. — 7bc98f26

Location docs rewritten as one example per tricky position: b38af33d. Dist: 85da5400, 41e67470.

Review-agent fixes also in this round: cb0865c1 (PropertyJsonPath TSDoc), 7c92b36b (two unfalsifiable assertions), d23d941f (chapter-region doc). Dist: 576b78c5, c3ff621c, faa45998, 5f2b4895.

Round 4 (2026-09-25): one position rule, no caveats

Executed from docs/superpowers/plans/2026-09-24-pt-4370-position-contract.md (core, gitignored) under its Rulings R1–R11; every task had a spec/quality review with fix rounds, then an Opus whole-branch review with one fix wave. Commits 41e67470..c5d0142f (SE HEAD c5d0142f; dist rebuilt in 9d13c82d and c5d0142f).

  • Byte aligner (markerEdit/usfmByteAlignment.utils.ts, 6dcbbc04): one constructive, validated live↔settled alignment across attribute re-spellings (named default → bare, dropped reserved keys and overridden duplicates, figure file→src), with per-literal inner alignments; value bytes never pair with name bytes on repeated text; unparseable attribute text pairs one-for-one; an attribute section ends at a placeholder (3d600224).
  • Positions through the aligner (ca08ff14, eb28143e, ce2786c6): pairRuns never refuses; sharedEnds/pairedBefore/documentCoords/NonWsCounts.document are gone; outbound snaps through the alignment; inbound checks existence in getUsj() first and then always resolves (unpaired runs → the run's front); the contract harness (positions/positionContract.test.tsx, 33 scenarios + specMapSnapped oracle) pins re-spellings, milestones, figures, notes, tables, chapters, sidebars.
  • Typed closers (R3/R4) (9e3f98c2, 8cdf6979): a caret inside a typed closer reports that closer's closingMarkerOffset; the boundary between two touching closers is exact; \+ opener byte pinned.
  • Typed table cells (R7) (d995982f, 8bf2c200): Character-typed \tc1 etc. settle as table:cell in an open row (ParatextData UsfmParser.IsCell parity; +-nested never a cell; no open row → char).
  • Mixed-root scopes (0c88db52): a paragraph scope that settles into a chapter, table or sidebar gets a settled fragment ($buildSettledRootFragment); a scope-end caret past a root-level sentinel spells the last leaf's end.
  • Stale basis → cache rebuild (84745c7a, 742d7d9e): $isPlanFresh guards the plan cache; STALE_BASIS is deleted; a moved-on basis logs settled-position basis out of date — rebuilt and snaps left.
  • Settle caret restore + mark carry (R7) (7b7cc6d4, 338ed25a, d4251862): one capture/restore through the alignment for the paragraph, note-content and chapter restores (measured shifts +7/+8/+9 and the carried-note fall-to-\p fixed); marks are dropped, never moved (glyph end, same count, collapsed); data-integrity fixes: $wrapSelectionInTypedMarkNode treats attribute-run text like a glyph (a comment over |lemma=" corrupted the attribute to lemma: "||||||||grace"; a plain wrap into a settled \w grace|grace\w* gave gr|grace), and a whole-span mark starts past the char's separator (the separator became a content byte " name" through a settle).
  • onUsjChange (R5) (6c77ac3b, a8789d81, 3d600224): ONE synchronous commit listener announces the settled document (equal to getUsj()) once per commit that changes the settled document or carries ops, in commit order, once after a setUsj reload; ops stay live; selection-only commits emit nothing; a local applyUpdate announces once from applyUpdate itself; an element-only commit (paragraph retag with one dirty leaf) is announced; a lossy load (space-run collapse) is not announced back as a change. Shared: registerPendedDisplayOwners(…, rederive) + ensurePendedDisplayOwnersCurrent; shared-react: $getUpdateOps / shouldSkipUpdateForOps extracted from DeltaOnChangePlugin.
  • selectNote(index) (R6) (d6eacdc8): counts settled notes (a pending typed literal gets the caret at its \); getNoteOps stays live for both — documented that the two can name different notes while a whole note literal is pending.
  • Block verse layout (d0b4f996): getSelection/setSelection/setAnnotation/onSelectionChange/insertNote all work (tree paths ↔ USJ paths via blockVerseLocations.utils.ts, every-point oracle against the inline layout); the isBlockVerse refusals are gone.
  • Docs (R9) (dbf507a8, 3d600224): every EditorRef/EditorProps position/index/key/ops/USJ member says Settled or Live; docs/standard-view-invariants.md states the one rule.
  • Recorded non-changes (R8): \c 2 made settles to a chapter then a bare root string "made" (ParatextData parity); the settle keeps the space before a typed \c/\tr/\esb/\esbe (positions count non-whitespace only, nothing moves); the web view's footnote pane (usjFromPdp) is untouched; the 700 ms-stale PDP host race is accepted. No changelog entry (Standard View is Unreleased).

Follow-ups found this round — all FIXED in round 5 below (kept for history):

  • A selection covering a whole inline AttributeRunNode wrapper is wrapped into a mark by CommentPlugin/setAnnotation (Lexical lists the element before its children): \qt-s |who="Pilate"\* loses the milestone from getUsj() (the display-run sync's remove-owner policy), and \v 2 \va 3\va* throws "transforms are endlessly triggering". Pre-existing, data loss + hang; the fix needs owner+run grouping ($preservedRunAt). File as a Jira bug before merge.
  • A caret on a paragraph ELEMENT point (after a trailing note) falls to \p@0 when a sibling literal settles; fix looks local ($captureCaret anchoring on the neighbouring child's span).
  • A remote applyUpdate whose DELTA_CHANGE_TAG commit dirties nothing but changes the selection leaves the tag on the editor and silences the user's next edit (pre-existing; a flag like the local-apply one fixes it).
  • After a lossy load, editedUsjRef still holds the host's raw USJ while the announce yardstick is the tree's export: getUsj() flips from raw to collapsed after the first no-op commit without an announcement (latent; seeding the cache from the export changes what getUsj() returns right after setUsj, so it needs a host-side check first).
  • The chapter caret restore pairs against $spansForNodes(newNodes) while the chapter mark carry pairs against $buildChapterFragment — no row pins chapter caret alignment.
  • $afterPrecedingClosingGlyph looks one span back (3+ touching closers untested); $scratchPointLocation untested for a sidebar as the document's last item.

Round 5 (2026-09-25): the round-4 follow-ups and remaining gaps, fixed

Commits c5d0142f..7747e142 (SE HEAD 7747e142; dist rebuilt in 7747e142). Every item below was a round-4 follow-up or a reviewer-found gap; each went through TDD (RED recorded), a review and fix rounds.

  • A selection covering a whole inline attribute-run wrapper, a verse, a note caller or a chapter glyph is never wrapped into a mark (2ec657fd, 0f9883cb, 04f46586). $wrapSelectionInTypedMarkNode (now nodes/features/typedMarkWrap.utils.ts, keeping nodes/ cycle-free) treats a display-run owner together with its AttributeRunNode wrapper — and a note's editable caller / a chapter's \c N glyph with their \cat / \ca runs — as one unsplittable unit: the mark closes before it and reopens with the same id after. \qt-s |who="Pilate"\* no longer drops out of getUsj(); \v 2 \va 3\va* no longer throws "transforms are endlessly triggering". Behavior change: a comment across a verse or milestone becomes two same-id marks (one per side, as across a closer glyph already), and a comment can no longer start on, or lie entirely on, a note caller or chapter glyph (those are display bytes). Also fixed: a cross-verse comment added a second space before the verse (TextSpacingPlugin's verse transform now skips the insert when the marked text already ends in a space or NBSP).
  • A caret on a paragraph element point survives a sibling literal's settle (3afc4257): $captureCaret anchors element points on the neighbouring child (shared $anchorForPoint), so a click past a trailing note no longer lands at \p offset 0.
  • getUsj() right after a load is the tree's own export (3f3ca32f): the load commit seeds the cache from the same export it announces from (host USJ only in the block layout), so a lossy load no longer flips silently on the first no-op commit. Core checked: every core comparison is whitespace-tolerant and loaded chapters are already space-regularized.
  • A no-op applyUpdate (remote or local) can no longer silence the user's next edit (ae87c926, 4d18d75a): Lexical keeps an update's tags when the commit dirties nothing, so DELTA_CHANGE_TAG rode onto the next keystroke; handleCommit, MarkerEditPlugin's destroyed-owner pend and the display-run sync now read an editor-scoped "applying an update" signal (nodes/usj/applyingUpdate.utils.ts, source-aware) instead of the tag. applyUpdate must not be called inside an update of the same editor (documented; a nested call logs an error and is announced once as a local edit without the caller's ops).
  • An element point inside a comment mark reports the child it names (56889ca4): the inline layout's "Known approximation" (snap to the mark's front edge) is gone; block and inline layouts agree everywhere, so blockVersePositions.test.tsx's two exclusions are removed.
  • Pinned, no code change needed (09ca10cc, 20087b21): a caret at byte 0 of the second and third of touching closers (\+add*\nd*\f*) is exact; a sidebar as the document's last item and a root-level char span at scope end spell the scope end correctly.
  • Cross-paragraph and backward comments (6b041080, 28d6d802, 686d5754): a comment across a paragraph boundary no longer pulls the next paragraph's \p separator into the mark; a BACKWARD cross-paragraph selection no longer overflows the stack in $locationFromNode ↔ $locationBeside (every real caret has a location again); a comment starting at a char opener keeps the span's separator out of the mark. Pre-existing data corruption fixed on the way: a comment starting at \nd over \nd LORD\nd* exported " LORD" (a fabricated byte) — the mark now starts after the separator prefix, using the same glyph-adjacency rule the export uses.
  • Not changed, by decision: duplicate attribute names in one section (|lemma="a" strong="s" lemma="b") keep snapping positions inside the unchanged middle attribute left — a monotonic byte tiling cannot express the reorder, so the snap is the correct answer.

Remaining follow-ups (small, recorded, not fixed): deleting only a \va value while the caret is in another paragraph is undone in the same commit by the display-run sync (pre-existing; normal UI editing keeps the caret at the deletion, which protects it); deleting one half of a split comment fires onRemove "destroyed" for the whole id (pre-existing); a verse-only or milestone-only selection produces no mark (consistent with the base).

Round 6 (2026-09-28): reordered attributes map exactly

Commits 7747e142..7237c379 (SE HEAD 7237c379; dist rebuilt in 7237c379).

  • A repeated attribute name no longer snaps positions (cdc72f9c, 279c428d, c8ed66b2). Typing |lemma="a" strong="G5485" lemma="b" settles to |lemma="b" strong="G5485" (a repeated name keeps its first slot with its last value), which reorders strong relative to the surviving lemma. The aligner used to pair attributes only in increasing order, so a caret or annotation inside strong snapped: outbound to the start of b, inbound to the end of the section (collapsing a setAnnotation there). It now pairs survivors by name and value regardless of order, so every byte of strong and of the surviving lemma maps exactly in both directions; a discarded attribute's bytes snap LEFT to just past |. Verified by the reviewer with a differential fuzz: sections with no repeated or dropped name emit byte-identical segments to the old aligner (108k cases, 0 differences), and ~180k fuzzed sections showed 0 tiling or round-trip violations. A range whose ends straddle a reorder comes back ordered (outbound) or keeps the host's direction (inbound), including on a figure's decorator-displayed attributes. R2-conformant change also recorded: a monotonic section with a duplicate or dropped key now snaps the discarded bytes instead of matching them onto the survivor's name character by character.
  • Note: annotations on attribute DISPLAY bytes are supported as of Round 7 (below).

Open item found while writing the core E2E spec — RESOLVED in Round 7 as not an editor bug (the editor maps this position exactly; the failure was a host-side selectRange-across-navigation race, recorded on core #2823): a selectRange to the very end of the chapter's LAST text (John 2:25, offset = length of the paragraph's last string) was reported on the preceding verse number's ['number'] location for the full poll. The E2E reviewer judged it a real inbound chapter-end boundary bug (no next sibling to anchor against). Needs a ticket or the next round.

Round 7 (2026-09-28): annotations on display bytes

Commits 8bc9a5a3..e26fd947 (SE HEAD e26fd947; dist rebuilt in e26fd947). Plan: docs/superpowers/plans/2026-09-28-pt-4370-annotations-on-display-bytes.md in paranext-core.

What changed. An annotation or comment can now cover the editor's DISPLAY bytes: an attribute run (|lemma="grace", \va 3\va*, a milestone's |who="Pilate", \cat …\cat*, \ca/\cp), a verse number, a note caller, a chapter's \c N glyph, and marker glyphs (\nd, \nd*, \p). Checks and other extensions reach these through UsjDocumentLocation (['lemma'] propertyOffset n, ['number'], ['marker'], closingMarkerOffset), which already named them exactly.

  • Representation (no tree change): display-byte nodes are never moved into a TypedMarkNode or split by one — that is what used to corrupt runs (lemma: "gr|grace", a deleted milestone, the \va transform loop). Such a node ("carrier") holds its annotations as Lexical node state, displayAnnotationsState (libs/shared/src/nodes/features/displayAnnotations.*): byte ranges plus the text they were measured against. A node transform re-maps the ranges through a character alignment whenever the carrier's text changes (typing, a display-run sync rewrite, a re-spelling), so a range follows its bytes; a byte typed at a range's edge stays outside it, and a range whose bytes are all replaced is dropped, never moved. The display-run sync, the marker-edit engine, the position layer and getUsj() read none of this state.
  • Painting and events: a per-editor, reference-counted index (libs/shared-react/src/plugins/usj/annotation/displayAnnotations.index.ts) paints a carrier's element with the same class names a <mark> gets (<typedMark>-<type>, the overlap class, annotationId-<id>) plus display-annotation, wires click/hover, and strips annotations from pasted nodes (a check result names one place).
  • Settle carry: the Tier-2 carry captures carrier ranges as byte anchors and restores them through the same wrap. The rule is by bytes: an annotation over bytes the settle KEEPS or RE-SPELLS is carried to their settled counterpart, content or display; over bytes it DISCARDS it is dropped. The carry no longer refuses glyph/run ends and starts a mark at its first covered byte (a char span's opener glyph included).
  • setAnnotation / removeAnnotation / CommentPlugin set, re-set and remove carrier state; a comment may sit on display bytes (alone or with text).
  • Also fixed: a range that only touches a text node at its edge (e.g. starting at R2's canonical position in front of \v 2, or at the end of LORD before \nd*) no longer marks that whole text node (702a7c21, pre-existing wrap bug these range shapes now reach directly).
  • Chapter end (pinned, not a bug here): a position at the end of a chapter's LAST text resolves and reports exactly in the editor (8bc9a5a3, 26e7c55b); the round-6 open item is a host-side race — see the core PR.

Visible consequences (intended):

  • An annotation covers every non-separator byte in its range: a comment across \v 2 also paints the verse glyph and its \va run; a range across \nd LORD\nd* paints the glyphs; a range across a paragraph break paints the next \p. A comment typed over a note literal's caller now paints the caller. The <mark> nodes themselves are unchanged.
  • Painting is per node, not per byte: a carrier is painted WHOLE even where the range covers part of it (a char span's whole |lemma="grace" strong="G5485" is one node, so a check naming only G5485 highlights the whole attribute list; a verse-number check highlights \v 12 ). Stored ranges are exact, so partial painting (CSS Highlight API or overlay rectangles) can come later without redoing this.
  • Separators (the whitespace between a marker and its content) never hold an annotation.

Flipped test rows (drop → carried, all over bytes the settle keeps): annotationSurvivesSettle "carries an annotation whose bytes became a char span onto its glyphs and its content"; settleCaretCarry "carries a comment over the moved value onto its settled bytes"; settleDifferential "carries a comment on the literal's caller onto the note's caller, never into its USJ". Every other regression fence (gr|grace, grace|grace, ||||||||grace, " LORD", the milestone remove-owner, the \va loop, the cross-verse double space, the second \cat/\ca run, the cross-paragraph separator and backward recursion) is unchanged and green, with carrier assertions added.

onRemove (next to PT-4804, which is unchanged): each <mark> reports its own removal, one call per mark. Display bytes report only for an annotation no mark reports: "removed" once on removeAnnotation or re-set; "destroyed" once when its last display byte leaves (an edit, a collaborator's edit, or a settle that discards the bytes) — but only for an annotation that never held a <mark> since it was set. A setUsj reload and undo/redo report nothing (as for marks).

getUsj() carries no display-byte annotation (an attribute value is a string; a glyph or verse number has no place for a zmsc). A CommentPlugin comment on display bytes ALONE is allowed; its thread loses its anchor on a reload, like a thread whose text was deleted. Hosts re-apply from their own anchors (core already keeps annotationInfoByIdRef).

Open questions / follow-ups (not done here):

  • For TJ — "destroyed" for an annotation set over a PENDING edit. setAnnotation on ['lemma'] 0..5 while |lemma="grace" is still typed text wraps a <mark> (the bytes are content then); the settle carries it onto the |grace run, and it is then "mixed" by the rule above, so deleting the span later reports nothing — whereas the same call after the settle reports "destroyed" once. Kept as specified (D5); documented in the TSDoc. Alternative: latch "held a mark" only when a mark still holds the id after the settle, so such an annotation reports once. Your call.
  • A range from content into PART of a char span wraps the whole CharNode (pre-existing wrap Case 3): e.g. from "the " into \nd or into LORD marks e \nd LORD\nd*. Needs "take an inline element whole only when fully contained; otherwise descend". Ticket.
  • Minor, deferred: a thread held only on display bytes gets the panel's interactive styling but clicking it does not select anything (click selects a mark start); an annotation from a verse number into content reports "2 " (the verse node's trailing space) as its covered text; acquiring the index runs one extra transform pass per editor mount.
  • Possible data loss found by the reviewer, outside this plan (reproduces with no annotation): a \w span whose content text lost the opener's NBSP (\wgrace|lemma="grace"\w*) makes getUsj() drop the span and the following text of the paragraph. Unknown whether real keystrokes can reach that shape. Ticket.

Round 8 (2026-09-29 – 10-01): exact annotation locations

Commits 04960aa5..cf559e1d (SE HEAD cf559e1d; dist rebuilt in cf559e1d). Plan: docs/superpowers/plans/2026-09-29-pt-4370-round-8-exact-annotation-locations.md in paranext-core.

The rule (E1). For setAnnotation, a CommentPlugin comment and the settle's restore, the bytes that hold the annotation (every byte under a <mark> carrying the id, plus every display-byte carrier range carrying it; a decorator counts whole) equal the bytes the range names. The only exceptions are separators and a carrier's own edge whitespace (the space in \v 3 ). Setting an annotation never changes getUsj(), the byte sequence, or the outbound label of any byte.

How it is proven. packages/platform/src/editor/annotationLocations/ holds a permanent oracle. It sets an annotation over every settled location range in eight views, then compares the holding bytes with the bytes named (derived independently from outbound labels). Expected failures live in committed JSON lists that can only shrink: an unlisted failure fails, a listed entry that passes fails, and a changed op set or exemption set fails. The lists for rich/edges (default suite, ~40 s) and for 2SA (opt-in, ANNOTATION_ORACLE_2SA=1, ~14 min) are empty: every location in every view holds exactly what it names. The edges corpus includes content no-break spaces (French mot~:, 1~000, end~, ~end, a~b, a span's leading or trailing NBSP).

What changed (main items).

  • Merged paranext/main at 04960aa5 (PT-4201: Standard-view copy/paste USFM fidelity #13 copy/paste, Read a press's button without naming PointerEvent #21 and others). The resolutions are in the merge commit; the round-7 display-annotation paste strip is pinned on PT-4201: Standard-view copy/paste USFM fidelity #13's paste paths (CRITICAL priority).
  • The wrap measures coverage by caret order.
    • Element points become leaf carets, and a collapsed or zero-width range annotates nothing.
    • An inline element (char span, note, figure, optbreak) moves into the mark whole only when the range covers it whole; otherwise the wrap descends and holds exactly the named bytes.
    • A whole-moved element never leaves an edge separator held.
  • Carriers now include the non-editable views' chapter decorator and an unmatched \*. A carrier range is clamped to its non-whitespace extent.
  • Inbound positions (annotation path only):
    • a milestone closer resolves;
    • undisplayed ATTRIBUTE bytes resolve to the end of content (leading properties stay at the element start);
    • an end inside a read-only decorator takes the decorator whole;
    • interior spaces in a decorator's attribute value count;
    • a milestone key snaps LEFT;
    • a read-only caller holds its note's undisplayed \cat bytes, and an empty span's read-only opener holds its undisplayed attributes.
  • Outbound labels:
    • a visible milestone's attribute decorator reports the milestone's attribute locations;
    • an unmatched \* reports ['marker'];
    • a caret behind a verse number's or note caller's own trailing space reports the next byte;
    • a verse at a non-last paragraph end reports its newline (['number'] len); the document end keeps len+1;
    • an element point in front of a \va run reports the run.
  • Data-loss fixes (found by the oracle and the reviews; most pre-existing):
    • an annotation splitting a figure caption moved caption text out of the figure;
    • editing text directly inside a note turned its spaces into no-break spaces, which getUsj() then dropped;
    • the marker menu's spacer in a note was saved as content when typed into;
    • an attribute list followed by text (\w |lemma="g"grace\w*) lost both from export;
    • an annotation splitting off an authored no-break space (inside or beside a mark, or leading a char span's content) dropped it from getUsj();
    • removeAnnotation or a plain edit deleted an authored no-break space leading a char span's content (taken for an empty span's placeholder);
    • pasting or typing into an empty span saved its placeholder or lost an authored no-break space.
  • Opener separators (screen == saved):
    • A deleted or typed-over NBSP after a char opener settles to the bytes on screen: an unknown marker is renamed, never healed. An annotation or other touch no longer puts it back.
    • In blocks no settle can re-tokenize (a table cell, UnknownNode, the \id line) it heals in place.
  • Removal reporting (TJ default 1): a single "last holder gone" reporter.
    • Each <mark> still reports its own removal (one call per <mark>, PT-4804 unchanged).
    • An annotation whose last holder (mark or display bytes) goes without its marks having reported gets ONE "destroyed". This covers select-and-delete, which never called remove() before and so reported nothing.
    • removeAnnotation or a re-set reports "removed" once.
    • Undo/redo stays silent and a setUsj load forgets.
    • A settle that discards an annotation's bytes reports once.
    • A throwing host onRemove goes to the editor's error handler after the commit.

Defaults in force (TJ; each can be flipped):

  1. Removal is reported by what holds the annotation NOW, not by whether it ever held a mark. Flip: the permanent "ever held a mark" latch; hosts then miss a set-while-pending annotation's loss.
  2. Painting stays whole-node. Superseded in round 9: painting is exact.
  3. Typing over or deleting an opener's NBSP renames the marker, never heals it. Flip: heal; this breaks "the bytes on screen are what is saved".
  4. A caret at a verse text's offset 0 reports that text location; extended to any verse number's or note caller's own trailing space. Flip: report the verse number's end.

Decisions made while implementing:

  • A fully covered inline element moves into the mark whole, its interior separator included (fewer marks: PT-4804 reports and zmsc pairs stay minimal).
  • A range starting ON a span's opening glyph never moves the span whole; it holds the glyph plus the content marks.
  • A comment into part of a span loses its opener-glyph carrier on save/reload, because display bytes carry no zmsc.

S1 (does a host annotation settle the user's typing?): not reproduced in the real app. Core's E2E pins two shapes: a saved word in the same text node as an unfinished |lemma="my, and a saved word in a separate node. A host setAnnotation resolves against the SAVED chapter, so a check cannot name unsaved pending bytes.

Open items: all fixed in round 9 except the lone-NBSP item (PT-4391) — see Round 9.

  • Typing between \w and its separator: the pending getUsj() shows a paragraph split, while the settle renames the span AND its untouched closer to \wx*. Pending ≠ settled; pre-existing; needs a design ruling.
  • In unformatted, the rename rule builds a marker name containing the NBSP and the span's content (\wj b\wj* + typed a → wja~b).
  • A lone-NBSP content string outside a mark is dropped on export: bare-NBSP display spacers are indistinguishable from content, so fixing it means tagging every spacer. The collab ops stream also drops NBSP+| and lone-NBSP content (kept for older peers).
  • A host onRemove that throws from a mark's own report runs inside the user's update and discards the edit. Core's handler is async, so core is not exposed.
  • Remaining minors (one-line notes in the round's ledger):
    • a multi-mark annotation's reported text names one mark;
    • the wrap tag covers a whole batched commit;
    • the oracle reuses the code's separator predicates for its exemption set (now pinned, so any widening shows);
    • a re-set batched in the same tick as a delete of the new range loses its report.

Round 9 (2026-10-01 – 10-02): exact painting, rename/closer, pending == settled everywhere

Commits cf559e1d..786c37f0 (dist rebuilt in 786c37f0). TJ's rulings for this round: annotations paint exactly where the location calculations say; a char opener rename renames its closer and getUsj() while pending equals the settled document; small low-risk follow-ups fixed rather than ticketed.

Exact painting (supersedes round 8 default 2).

  • A leaf every annotation covers whole keeps its element classes. A partly covered display byte (e.g. G5485 inside |lemma="grace" strong="G5485", the 2 of \v 12) is painted with CSS Custom Highlights over exactly those characters. The editor styles each highlight from the host's own class rules (background-color, color, text-decoration, text-shadow; a bottom border becomes an underline), so hosts change no CSS. Interior gaps between held bytes in one block are painted; edges never; undisplayed bytes paint nothing.
  • Read-only decorators hold the exact part a range names. Holds are stored in the bytes a decorator stands for, so a collapse/expand or another write never widens one.
  • Overlap is per character; click/hover go only to the annotation under the pointer; a comment's selected state paints only its part.
  • New API: EditorRef.getAnnotationRanges(type, id): Range[] (also on AnnotationRef/MarginalRef). Core now looks annotations up through it; the annotationId-<id> class no longer exists on a partly painted display byte.
  • The annotation oracle also grades painting (painted == held + interior gaps) in every view, per decorator character; lists empty (default and 2SA).

Collapsed notes follow Paratext 9. An annotation inside a closed note's content — \cat included — is not shown and never painted on the caller (PT9 Standard.xslt renders a closed note as its caller only; Annotator.cs never anchors to \cat). The hidden content keeps its holders and still reports removal.

Rename and pending == settled.

  • A char opener rename renames its closer on settle (\w grace\w* + x typed before the separator → \wx grace\wx*), and the pending getUsj() already shows it. Name bytes typed before the separator move into the glyph as typed, so both on-screen spellings settle the same. Unformatted no longer builds wja~b.
  • Why the old proof missed it: the pending == settled suites listed hand-picked shapes and none renamed a closed span's opener. A new generated oracle (markerEdit/pendingSettledOracle.*, four views incl. the footnote popover's protected shell) types every single keystroke and delete-then-type at every caret position around marker glyphs/separators, with caret-then-type as separate updates and undo/redo after departure, and checks: pending getUsj() == settled; screen == saved byte for byte (whitespace normalized only where ParatextData's RegularizeSpaces does); the same character at the same screen place saves the same file; every pending caret position exact-or-closest-left, and inbound round trips. It found ~530 older pending-position failures and a series of data bugs; all fixed, lists empty.

Data bugs fixed along the way (most pre-existing):

  • an expanded note's caller merged into its content text and was saved twice; damaged callers leaked caller bytes into content; deleting the caller and retyping lost the new caller (now PT9-style: the first word typed becomes the caller); undo/redo of a waiting caller was never re-settled; collab ops disagreed with getUsj() while a caller waited;
  • a second \ca/\cp/\va/\vp/\cat after a chapter/verse/note overwrote the first (bytes lost) — the tokenizer now folds each once, in ParatextData's order (UsfmParser.cs:320-339, 548-576);
  • bytes typed into an expanded note's \f/\f*, an unmatched marker, or at a milestone closer's end were never saved; x typed at the start of |g was never saved; an unformatted chapter edit never settled;
  • a | or space typed at a char opener's glyph end saved a different file than at the content front (lemma " grace"); a space typed beside a verse/caller/glyph saved a byte ParatextData drops on reload;
  • footnote popover: deleting or typing at the closer lost bytes or unclosed the note; text typed after the closer or via a range reaching the shell's front landed outside the note (dropped by the popover's save); select-all + type removed the note. A protected note shell now keeps every keystroke inside the note.
  • a preserved-node placeholder could join a marker name/caller word (\p → \x before a footnote); it is now a hard token boundary, and note/verse placeholders close an open note as UsfmParser.CloseNote does.

Small follow-ups fixed: a mark's throwing onRemove goes to the editor's error handler after the commit (the edit survives); a multi-mark/multi-hold annotation's "destroyed" text joins every held piece; JSON removal keys; the oracle pins its op set by hash and has hand-written exempt rows independent of the code's predicates; listener cleanup on index release; highlight names collision-proof.

Open (not fixed; tickets recommended):

  • A paragraph rebuild of a\f + \ft note\f*\v 2 b saves [note, " ", verse] — a space gained between a note and a following verse (pre-existing rebuild/tokenizer path).
  • The tokenizer gives an empty caller + where ParatextData writes caller=""; following ParatextData breaks the editable-caller code, so it was left (newly reachable via \p→\x in front of a note).
  • A space typed after a char opener is saved as content (\w grace), which ParatextData drops on reload (UsfmToken.cs:360); ratified by typedSeparatorSpace tests, needs a ruling.
  • Non-Standard views: a lone NBSP content string is dropped on save and its location reports to the right — PT-4391.
  • A bare \ as the first byte after a marker: ParatextData writes \ (adds a space); ours keeps \.
  • Deferred minors: the wrap-tag skip covers a whole batched commit (unreachable in the app; comment fixed); a re-set + delete of the new range in one synchronous tick loses its "destroyed".

Round 9 addendum (2026-10-12): 786c37f0..ffee10a4

  • Unformatted: content touching a verse no longer gains a space on rebuild (edaea869). The view's display-only line break before a verse was written into the rebuild fragment as " ", so a\f…\f*\v 2 re-read as …\f* \v 2 (saved USJ gained " "; a \p rename showed it only while pending). New Unformatted oracle rows: text, a note and a char span each touching \v 2.
  • A nameless paragraph marker round-trips (c97ed179, 0c4756d2). ParatextData (9.5 and 9.6) reads \p \ df as <para style="" status="unknown">. The utilities converters treated style="" as missing, so the editor showed \p, saved style="" over a user's rename (rename lost), and rebuilt the paragraph into \p + literal text. Now marker: "" round-trips, the glyph shows a bare \, a rename saves, and the paragraph is preserved (not re-tokenized) while its glyph names no marker. Typed \ tokenizing is unchanged (mid-text \ stays literal, as in ParatextData 9.6). Core consumers will see marker: "" where they used to see style: "" with no marker.
  • Listed, not fixed (reasons in the oracle lists):
    • A space typed at offset 0 of a mid-paragraph verse glyph ( \v 2) stays in the glyph and never reaches the file (Standard and Unformatted; ParatextData normalizes the space before \v anyway).
    • In a nameless paragraph, bytes typed beside the bare \ without forming a marker name (or deleting the \) are not saved, because the paragraph is preserved rather than re-tokenized.
    • Standard: TextSpacingPlugin inserts a " " before a verse that follows a char span when a rebuild creates the verse, which the pending side doesn't mirror (ParatextData also inserts that space on load). Product call: keep and mirror it, or stop inserting it.
  • Pending (after scripture-editors PT-4189: EditorRef note index/key/highlight and keep DOM focus on unfocused applyUpdate #15 / paranext-core #2805 merge): switch the tokenizer's empty-caller default from + to "" (ParatextData writes caller="") and make this PR's caller handling accept an empty caller.
  • Tests: platform 3341 (+14 skipped), shared 856, shared-react 1866 (+1), utilities 63; typecheck 10/10; lint clean on touched files; dist rebuilt, verify-committed-dist.mjs ✓.

Round 10 (2026-10-14): /code-review high follow-ups (29b2ceb0..86953282)

A 17-finding review of both PRs. Fixed here (each with a test that failed first):

  • A display-only annotation's removal is always reported. removeAnnotation decided whether its marks had already reported by the plugin's mark-key index, which keeps a key for a mark that lost the id but still holds another. An annotation that had once shared a mark with another one, and was later set on display bytes only, was then removed silently, so core never ran the extension's interactionCommand. It now counts the marks that actually held the id.
  • A host's throwing onRemove on that path no longer discards the removal; it reaches the editor's error handler after the update, like the mark and index paths.
  • A throwing applyUpdate no longer makes the user's next edit look remote. The "applying" signal is cleared and the stranded onUpdate does nothing, so the next edit is announced as "local" with its own ops.
  • A caret at a closing marker stays a caret when the host spells its end as a copy of its start ({ start: loc, end: { ...loc } }), rather than becoming a backward range over the space before \f*.
  • Clicking a comment thread activates it when the comment covers only part of a display-byte carrier (e.g. one digit of \v 12): the caret goes to the start of the comment's own hold.
  • The highlight painter keeps a reused range only in the class set that still paints it.
  • EditorRef.getAnnotationRanges is marked @experimental (86953282). It stays the way to find painted annotations; a planned follow-up that paints every annotation with elements again keeps this method working, so it should not need a breaking change.
  • Cleanups: one helper orders an annotation range's ends for both the edge and the decorator-hold computations; scribe imports UNMERGEABLE_TEXT_DETAIL from shared; a Jira ID and a review label removed from tests.

Not changed (checked and dropped): per-commit repaint cost (it compares stylesheet rule counts, not rules, and scales with the few multi-holder annotations); the block-verse unit walk per selection change; the per-depth PropertyJsonPath members (deliberate, documented).

Tests: platform 3791 (+14 skipped), shared-react 1964 (+1), shared 880, scribe 4; typecheck + lint clean (warnings only, none in touched files); dist rebuilt, verify-committed-dist.mjs ✓.

Round 11 (2026-10-15 – 10-21): lyonsil's review (86953282..5748bb2d)

The review of 2026-09-24 was written against round 3 (41e67470). Every finding was re-checked against 86953282 first: 17 were already fixed by later rounds (#1, #2, #4–#8, #10, #11, #15, #16, #18, #20, #23, #24, #27–#30 — mostly by the carrier-aware mark wrap); the rest are fixed here unless noted.

  • setSelection at the document end puts the caret at the end of the last block, so typing there stays in the paragraph instead of creating text outside every paragraph (8b0405cc, Flaky interaction tests: ScriptureReferencePlugin and marker-palette each failed once in CI #3). The document-end spelling is unchanged (an owner ruling): a caret behind a verse that ends the document still reports one past the final newline, and a document ending in text reads back as the end of its last line.
  • A verse number at the end of a line (owner ruling, 2026-10-20): USFM writes \v 2 then the newline, with no separator, and the browser does not draw the glyph's trailing separator there (End, a click past the line and ArrowRight put the caret right after the number). So every caret after a line-final number — the last line's included, and among spaces typed there — reports ['number'] at the number's length; that location lands right after the number; one past it on a non-last line lands where the next line's marker location does; the document end lands at the end of the last line. A line-final number no longer reports the document end. Chapter lines unchanged (9b351b4f, replacing b0aebab4).
  • \v typed at the end of a paragraph shows its space: a number-less verse (or chapter) glyph is \v + NBSP instead of a plain space the browser hid at a line end, so the number can be typed after it; typing text right after a line-final number no longer saves "t ext" (4ab43feb).
  • getUsj() drops the plain spaces at the start of a paragraph and right after a verse number in Standard view, as ParatextData's tokenizer does (a deleted char marker's separator at a paragraph's start, typed spaces after \p or \v 2 ); not after \va/\vp/\ca/\cp, and a ~ NBSP stays. The screen keeps what was typed, and the position model drops the same characters so locations index the saved USJ. (c3b2f878)
  • Pasting over the letters of an in-note marker renamed it (\ft → \ftnote, logged "Unexpected char marker 'ftnote'"), a regression from the separator-gap rename rule (fc71a2c2). Lexical's paste splits the span and moves ~note text into a new span, and $charTextNodeTransform dropped that leading NBSP (the opener's separator) as if it were a new span's placeholder. A span created in the update now drops a leading placeholder only from text that held no content in the last committed state, which also keeps an author's ~b in the views without glyphs. The pasted copy still lands after the note (pre-existing, filed separately) (c3f6e614, merged in 1a56d462).
  • A paragraph's trailing spaces are deliberately not dropped (owner ruling, 2026-10-20). They never change the file: ParatextData's writer removes the space before \p, \c, \id and \v and at the end, and always writes exactly one space before an unknown-marker paragraph (\zCustom, \ts), so 0, 1 or 2 trailing spaces save byte-identical files (verified through SetChapterUsx). They only make the editor's USJ differ from what a reload sends back, which core's echo comparison already ignores. Trimming them with the project stylesheet (already in the editor as markerLookup) is recorded as an optional improvement on PT-4199.
  • setUsj right after an annotation no longer lets a stale settled read win: getUsj() and the reload use the incoming document (90fd4457, Rebuild the committed platform dist after PT-4304 #9).
  • insertNote refuses (and logs) an anchor on a chapter line, where the note was never saved, and puts a note at a paragraph's own marker location after the paragraph's prefix instead of merging the paragraph into the previous one (d1f22c58, PT-4201: Standard-view copy/paste USFM fidelity #13, Fix the editor context menu and \id-line editing #14).
  • Re-setting an annotation id resolves its range after removing the old marks (14953682, Let a host suspend the gesture-free marker-settle clocks (unblocks PT-4611) #25).
  • A comment's zmsc-e milestone is written even when a non-comment mark (a Find highlight) follows it, so a reload no longer drops the paragraph's last content (26f1e4c7, PT-4370: Paint every annotation as classes on elements #26).
  • Settle (ff478a0d): a char span annotated from its first letter settles (it never reached a fixed point, so a marker literal in that paragraph never settled — Read a press's button without naming PointerEvent #21); settled reads while an edit is pending no longer call hosts' onRemove("destroyed") for overlapping annotations (PT-4540: Make a paragraph's gutter marker selectable #22); a mark ending at a closer no longer grows over a following footnote (Put a fallback chain behind every font the project stylesheet names #12).
  • An annotation over nothing but a footnote that ends a char span (\w word\f + \ft …\f*\w*, after the word itself is deleted) was dropped, and reported destroyed, when anything else in the paragraph settled: its two ends resolved to two spellings of one boundary. An annotation on the \id line, or in a paragraph the unknown-split rejoin folds into it, was dropped whenever anything on that line settled: the book-line settle carried no marks. Both are carried now, in the live and the read-only settle (80f28aef, Put a fallback chain behind every font the project stylesheet names #12 leftover).
  • Annotating text that runs up to a \v no longer writes a space into the document (15bcec0f; found while checking Adopt this repository into the paranext organization #1/Re-add transfer guide #7/PT-4370: Settled positions across the public position API #11).
  • Backspacing one of two spaces between char spans keeps the remaining space in the saved file (9c6baead, PT-4608: Keep chapter-line edits from losing text or the caret #19), and so does the same backspace inside a char span (between nested spans, or before its closer), inside a footnote's text, and between an expanded footnote's spans (84a68a31). A space next to a comment highlight was already kept. Deleting the opening glyph of a span that starts with an element, or of an empty span, no longer saves the spacer that followed the glyph as a space (58c2b380, lyonsil's follow-up).
  • Docs (bbce916b, fa1128ce): the location model's text-end alias and tricky line ends (mirrors core); paranext-core now re-exports these types, so this is their only declaration. (Let the host choose where the context menu renders #17)
  • The first Backspace at the end of a protected footnote's text deleted nothing: a caret past the content rests at the closer's front, and the delete guard swallowed a Backspace there. It now deletes the content's last character (d268a0e0; a regression from this PR's 35800a06, found while fixing the noteShellProtection flake).
  • Tests: the note insertion test types its location literal, which the package typecheck requires (7cb39755); noteCaretPlacement "leaves nothing behind when the caret moves on without typing" no longer depends on when jsdom delivers selectionchange (it failed intermittently on macOS CI) (507b114f); neither do the note-shell guard rows in noteShellProtection (5 of 20 runs failed; bedcdff2) or the backward-press row in ArrowNavigationPlugin.trailingNoteInSpan (e7d89780).
  • Merged origin/main (07fc987c, PT-4611 Let a host suspend the gesture-free marker-settle clocks (unblocks PT-4611) #25; only dist conflicted).
  • Dist rebuilt (ee1219d5, dedc4033, 47bb5baf, 02f8a1ae); verify-committed-dist.mjs ✓.

Not fixed here: #31 (caret positions right after deleting // inside a footnote) is PT-4862.

Tests (2026-10-20, 02f8a1ae): platform 3915 (+17 skipped), shared 883, shared-react 1968 (+1), shared 880, utilities 63, scribe 4; nx run-many -t lint and -t typecheck 10/10.

Round 12 (2026-10-21): /code-review high of the changes since Matt's last review (5748bb2d..73c7571f)

  • A caret among spaces typed after a paragraph-final verse number stays there when a host restores it. After Space at the end of \v 2, the caret reports the number's end (the Round 11 ruling: those spaces are no bytes of their own), and paranext-core hands that location back when the marker palette or a popover closes with the editor's selection lost. setSelection put it right after the number, so the next letter made verse number 2t. setSelection now first tries the last caret the editor observed: if its text is still in the document and a caret there reports exactly the requested location, that caret is put back; otherwise the location resolves as before (the number's end still lands right after the number when the user's caret was elsewhere). getSelection reads back what was set either way (14ddb795).
  • Enter right in front of an author's ~ inside a char span lost the ~ (a regression from this PR; main keeps it): in the views without glyphs, Enter in \w ab~cd\w* before the ~ saved \w cd\w* for the new paragraph. The split's second half is a text the update created, in a span it created, and the placeholder transform's new-span case dropped its leading NBSP. A caret still at that text's start means nothing was typed into it, so it keeps its byte, as on main. This PR's 297e9531 had lost main's caret-at-start guard there; no other test reaches that case (138b3bae).
  • Test: "reports a caret placed at a text boundary exactly once" failed intermittently on macOS CI: jsdom fires selectionchange from a setTimeout(0), which React's async act does not reliably wait for. It now drains queued events after mounting and after the placement (8d44352c).
  • Merged origin/main (bb67e838, PT-4540 PT-4540: Make a paragraph's gutter marker selectable #22): editor.model.ts docs conflicted and keep both sides; getSelection's docs now say it returns undefined when a paragraph marker is selected (getSelectedParaMarker), not only with no selection.
  • Dist rebuilt (73c7571f, b791f565); verify-committed-dist.mjs ✓.

Reviewed and left as is: \ f Space now has the same window as Enter between the palette's close and the note's insertion (core #2823); $getLogicalPointFromElementPoint rebuilds the root's items per skipped child on a root-level implied paragraph (negligible).

Tests (2026-10-21, after the merge, b791f565): platform 3970 (+17 skipped), shared-react 2097 (+1 skipped), shared 895, utilities 63, plus 4 + 3 in the smaller packages; nx run-many -t lint and -t typecheck 10/10.

Known gaps and pre-existing bugs found on the way (NOT fixed here; ticket drafts in PT-4370)

  1. LogicalTextItem.length is not the USJ string length in Standard view when the text carries a run of two or more spaces: that view's exporter also applies normalizeSpaceRuns, which is not length-preserving, so offsets after such a run are shifted. The exporter-oracle's collapsedItems === [] assertion passes only because the 2SA corpus has no space run; it is marked a deliberate waiver that goes red if such a corpus is added. Deliberately not fixed here: the logical content model and the whole $getLocationFromNode / $getNodeFromLocation layer take no ViewOptions, and the collapse cannot be detected from tree shape (an NBSP is a display space in Standard view and data everywhere else) — so a per-segment settled-offset map needs the view threaded through that layer's whole public surface, which is not a change to fold into this PR. FIXED in follow-up 10.
  2. The settled-positions module's remaining refusal paths still have no test coverage. FIXED in follow-up 14.
  3. The plan cache in settledScopes.utils.ts keys a scope's plan on fragment text only, so a setAnnotation inside a pending scope (which splits text nodes) can hit a stale plan and hand back an out-of-range live point. FIXED in follow-up 12.
  4. TypedMarkNode callback registries and AnnotationPlugin's key map are never pruned for a mark that is spliced away rather than removed; the annotation carry now abandons one entry set per settle of an annotated scope (bounded by session length).
  5. packages/scribe's editor→USJ adaptor still has the byte-comparison caller drop the platform adaptor replaced, so the two adaptors disagree about a data-loss rule.
  6. ImpliedParaNode positions (found 2026-09-21, predates this branch). Text before a document's first \p loads as an implied paragraph that the logical model counts as ONE top-level item while the exporter splices its children into the root, so every position inside or after it names the wrong USJ index — in Standard and Unformatted, through the existing annotation/selection APIs as well as the settled ones. With [book, chapter, verse, "orphan text ", para], a caret in the paragraph reports $.content[3].content[0], which in getUsj() is the orphan string. The fix makes the implied paragraph transparent wherever the position layer walks, which touches every location consumer; it needs its own ticket. FIXED in follow-up 17.
  7. The non-Standard createCharMarker separator strip still removes a leading NBSP from a char span's first string whether or not it follows an opening glyph (the Standard strip was narrowed in follow-up 3). Latent: no natural route to a disagreeing shape was found.
  8. Two import cycles inside libs/shared/src/nodes/usj/ (node.utils ↔ attributeDisplay.utils, and node.utils → markerSeparators.utils → nestedGlyphs.utils → node.utils). FIXED in follow-up 19.
  9. setAnnotation over a pending paragraph's own text settles it inside the annotation's update, whose ANNOTATION_CHANGE_TAG keeps that change from onUsjChange, so until the next edit getUsj() returns the cached pre-settle tree (the typed literal as text). FIXED in follow-up 35.
  10. Marks after or inside a typed footnote literal are dropped when that paragraph settles. FIXED in follow-up 47.
  11. A caret inside a typed \fig … \fig* literal has no settled position. FIXED in follow-up 50.
  12. A chapter rebuild beside an unclosed \ca span leaves that span's fresh text pended after the commit (the adjacency arm pends any dirty region text). It settles as a no-op on the next departure.
  13. An optbreak emptied while the caret is elsewhere is not recorded as pending, so getUsj() still shows the optbreak while a commit removes it (found 2026-09-23, pre-existing).

Testing

  • 2026-10-19, round 11 close-out (HEAD 47bb5baf): nx run-many -t lint, -t typecheck (10/10) and -t test green: platform 3859 (+14 skipped), shared-react 1968 (+1), shared 880, utilities 63, scribe 4; dist rebuilt, verify-committed-dist.mjs ✓. noteShellProtection.test.tsx no longer fails intermittently (bedcdff2: 48 runs, 16 of them concurrent, all green).
  • 2026-10-02, round 9 (HEAD 786c37f0): platform 3322 (+14 skipped: opt-in 2SA files), shared-react 1866 (+1 skip), shared 856, scribe 4, utilities 59 — green; lint clean on touched files; nx run-many -t typecheck 10/10; all four pending/settled oracle views and the default annotation oracle green with empty lists; 2SA annotation oracle (ANNOTATION_ORACLE_2SA=1) 8/8; dist rebuilt, verify-committed-dist.mjs ✓. Core e2e: see core #2823.
  • 2026-10-01, round 8 (HEAD cf559e1d): platform 2984 (+11 skipped: the opt-in 2SA oracle files), shared-react 1828 (+1 skip), shared 829, utilities 59, scribe 2 — green (--pool=forks --maxWorkers=3); nx run-many -t lint 0 errors; nx run-many -t typecheck 10/10; 2SA location oracle (ANNOTATION_ORACLE_2SA=1) 8/8 with empty lists; dist rebuilt, verify-committed-dist.mjs ✓. Isolated e2e in paranext-core (npm run build:editor from cf559e1d): standard-view-annotation-positions 1/1 (4.1 m), standard-view-annotation-shapes 1/1 (2.8 m), standard-view-space-run-positions 1/1 (1.1 m), attribute-display-settle 1/1 (2.5 m), type-through-save-echo 1/1 (1.2 m).
  • 2026-09-28, round 7 (HEAD e26fd947): platform 2431, shared-react 1769 (+1 skip), shared 752, utilities 59, scribe 2 — green (--pool=forks --maxWorkers=4); nx run-many -t lint 0 errors (10 projects); nx run-many -t typecheck 10/10; dist rebuilt, verify-committed-dist.mjs ✓. Isolated e2e in paranext-core against this editor (npm run build:editor from e26fd947): standard-view-annotation-positions 1/1 (4.2 m; attempt 1 timed out on a pre-existing step, Playwright's retry passed every step including the new display-byte annotation and chapter-end steps), standard-view-space-run-positions 1/1 (1.6 m), standard-view-annotation-shapes 1/1 (2.9 m; its mark-scoped assertions unchanged).
  • 2026-09-28, round 6 (HEAD 7237c379): platform 2374, shared-react 1757 (+1 skip), shared 720, utilities 59, scribe 2 — green (--pool=forks --maxWorkers=4); nx run-many -t lint 0 errors; nx run-many -t typecheck 10/10; dist rebuilt, verify-committed-dist.mjs ✓ (rollup 1934 lines). Isolated e2e in paranext-core against this editor (npm run build:editor from 7237c379), all first try: standard-view-annotation-positions 1/1 (1.2 m), standard-view-space-run-positions 1/1 (54 s), and the new standard-view-annotation-shapes 1/1 (2.4 m; six controller-driven scenarios: comment across a verse number, across a typed \qt-s milestone, starting at a typed \nd span, across a paragraph break incl. a backward selection, a comment after a \w span through an attribute re-spelling, a caret past a trailing footnote through a settle).
  • 2026-09-25, round 5 (HEAD 7747e142): platform 2353, shared-react 1757 (+1 skip), shared 720, utilities 59, scribe 2 — green (--pool=forks --maxWorkers=4); nx run-many -t lint 0 errors; nx run-many -t typecheck 10/10; dist rebuilt, verify-committed-dist.mjs ✓ (rollup 1934 lines). Isolated e2e in paranext-core against this editor (npm run build:editor from 7747e142): 2026-09-28, foreground, both specs PASSED first try on this build — standard-view-annotation-positions 1/1 (3.5 m, no retry), standard-view-space-run-positions 1/1 (1.4 m).
  • 2026-09-25, round 4 (HEAD c5d0142f): platform 2329, shared-react 1752 (+1 skip), shared 709, utilities 59, scribe 2 — green (--pool=forks --maxWorkers=4); nx run-many -t lint 0 errors (10 projects; warnings pre-existing); nx run-many -t typecheck 10/10; dist rebuilt with pnpm rebuild-committed-output, verify-committed-dist.mjs ✓ (rollup 1929 lines, no Editorial.d.ts). Isolated e2e in paranext-core against this editor (npm run build:editor from c5d0142f): standard-view-space-run-positions passed first try (1.1 m); standard-view-annotation-positions passed on retry (the cold attempt hit the pre-existing OutPortal web-view recreation at a pre-existing step), and its new same-paragraph step passed 2/2 on the previous build.
  • 2026-09-23, after follow-up 51: platform 2219, shared-react 1753 (+1 skip), shared 702, utilities 59, scribe 2 — green; nx typecheck + lint clean (5 projects); dist verified current. E2E not re-run.
  • 2026-09-23, after follow-up 49: platform 2155, scribe 2 — green; nx typecheck + lint clean incl. scribe-editor; dist verified current.
  • 2026-09-23, after follow-up 46: platform 2137, shared-react 1747 (+1 pre-existing skip), shared 702, utilities 59 — green; nx typecheck + lint clean for all four; dist rebuilt and verified current. E2E not re-run this round.
  • 2026-09-22, after follow-up 36: shared 702, shared-react 1744 (+1 pre-existing skip), platform 1879 — green. Isolated e2e in paranext-core against this editor (npm run build:editor from 58a00f78): the space-run spec passed first try, and the annotation spec passed on a clean re-run (its cold attempt in the first run hit the pre-existing OutPortal web view recreation); nx lint + typecheck clean for shared, shared-react, platform, platform-editor, scribe-editor; every new test row checked to fail with its fix reverted. Isolated e2e in paranext-core against this editor (npm run build:editor from 728650f4): both settled-position specs passed first try.
  • 2026-09-22, after follow-up 22: shared-react 1741 (+1 pre-existing skip), platform 1842 — green; typecheck clean (shared-react, platform and the projects they depend on); eslint clean on the touched files. shared, scribe and utilities are untouched by follow-ups 20–22 and were not re-run.
  • shared 702, shared-react 1739 (+1 pre-existing skip), platform 1839, scribe 2, utilities 59 — all green (2026-09-21, after follow-up 19); typecheck clean (10 projects); lint 0 errors on the touched packages.
  • 2026-09-18, after follow-up 10: shared 699, shared-react 1735, platform 1807, scribe 2, utilities 59; tier2Rebuild.corpus 141 checked / 0 skip-listed.
  • New: exporter-oracle property test; 79-row snapping table asserting both directions; seven-subtype tables inbound and outbound under an unrelated pend; failure-mode rows (index shift; transient-input cut); split / rejoin / note / chapter scope rows; EditorRef-level tests through a mounted <Editor> (deferred report ordering, coalescing, unmount cancellation, synchronous identity path); a refusal row for diverged sentinel lists; a nested-scope suite (note settling inside its own settling paragraph) covering both translation directions, the husk-only paragraph plan, an element boundary beside a preserved node, and a refusal when the byte correspondence is missing; mark-transparency rows for the note caller slot on both the live and the serialized side, and for the caller's display bytes in the snapping table; element-start rows (a char span, a split's second paragraph) inbound; a settle row for an annotation holding only a note.
  • Committed packages/*/dist rebuilt with pnpm rebuild-committed-output; node scripts/verify-committed-dist.mjs → "Committed build output is current. ✓". node scripts/verify-consumer-lockfile-sync.mjs → in sync.
  • On a memory-constrained box run the full suites with --pool=forks --maxWorkers=4.

AI Involvement

AI-assisted — session; follow-ups: local session 8c6d82a0-1eea-4a50-93e4-5e9bf0cad139; review-paratext fixes: session ab15f9a8-d773-443c-b080-4e162cb30bb2; open-item close-out (follow-ups 8–9): session 73a8b035-072d-4680-8d69-4a6118e63c4c; space-run fix (follow-up 10): session 7a82c6b7-bc2c-4048-9099-88868317295b; review-summary close-out (follow-ups 11–19): session 2b28f20d-1a3e-4b9f-8e51-8501d1be43bf; code-review fixes (follow-ups 20–22): session 50c606e5-aa9d-402f-b8df-5bae55fed25e; code-review round 2 (follow-ups 23–36): session 09771ed2-a8df-4f8f-b20e-9ac9f1b67324; code-review round 3 (follow-ups 37–51): session 298eb83f-d245-418f-a797-b91afb473361. Round 4 (one position rule, 2026-09-24/25): controller session d2b5c2bf-7514-463d-bef9-037957c13ddb — Opus implementers/reviewers for the aligner wiring, mixed-root scopes, settle carry and onUsjChange; Sonnet for the rest; Opus whole-branch review + one fix wave. Round 5 (follow-ups + gaps, 2026-09-25): same controller session (link), Opus implementers and reviewers. Round 6 (reordered attributes, 2026-09-28): same session. Round 7 (annotations on display bytes, 2026-09-28): controller session 2c4fe30c-6a8b-42a8-ba57-9141bb0ab91f — Opus implementers/reviewers for the chapter-end pin, the wrap, the index, the settle carry and the per-kind rows; Sonnet for the rest; Opus whole-branch review + one fix wave. Plan, implementation, tests and reviews were generated by Claude subagents under a controller session; every task went through a spec/quality review and a whole-branch review with fix rounds; the human developer directed scope and decisions. Round 8 (exact annotation locations, 2026-09-29 – 10-01): controller session 2c4fe30c-6a8b-42a8-ba57-9141bb0ab91f — three investigation agents (selectRange race, NBSP report, location audit), an Opus-written and Opus-reviewed plan, Opus/Sonnet implementers with a task review and fix rounds per task, an Opus whole-branch review and one fix wave. Round 11 close-out (2026-10-19): local session afd4c973-53e8-47e8-abd0-dbebcb0eb397 — three investigation/fix agents (#12 leftover, #19 leftover, \id line carry), each mutation-checked. Round 12 (2026-10-21): local session f5ab1039-b45a-4579-903b-b006d1b1c0a0 — a /code-review high agent, then fixes verified by probes on this branch and on main.

Risk Level

Medium — the logical-model and selection.utils.ts changes affect every annotation and selection in every view (covered by the exporter oracle and the 2SA data-driven suites with zero regressions); the translation module only runs while a marker edit is pending and snaps left rather than refusing (it refuses only a location that names nothing in getUsj()). $wrapSelectionInTypedMarkNode now holds an annotation ON display-byte nodes (never moving or splitting them) and no longer marks a text node a range only touches at its edge, which changes how every annotation over a glyph, run or verse is represented in every view (covered by the AnnotationPlugin, annotated data-driven and settle suites).

🤖 Generated with Claude Code

https://claude.ai/code/session_012p1aLgN78tWXKFHgj6AhTu


This change is Reviewable

@lyonsil lyonsil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review of the settled-positions change covers 30 findings, most of them in how annotations interact with Standard-view glyphs and with pending marker edits. Each comment shows its severity, and a status of checked and confirmed means the finding was verified against the code at this head. Findings that sit on lines this PR did not change are collected below rather than inline.

Findings that could not be attached to a line in this diff

#1 (high · checked and confirmed) libs/shared-react/src/plugins/usj/TextSpacingPlugin.tsx:217 - When a VerseNode ends up inside an annotation mark, the editor throws "One or more transforms are endlessly triggering additional transforms", and later edits stay uncommitted until a flush adds about 100 spaces before the verse.

What happens: $verseNodeTransform (217-220) inserts a space in front of a verse whose preceding text does not end in one. When the verse sits inside a TypedMarkNode, the inserted space lands where the check does not see it, so every transform pass inserts another until Lexical's transform limit throws. The rethrowing onError (packages/platform/src/editor/Editor.tsx:478-479) leaves the pending marker-edit state in place.

Why it matters: Any path that places a verse inside a mark (a carry over typed \v , a host range crossing a verse) turns into a stuck editor rather than a cosmetic glitch.

Fix: In $verseNodeTransform (libs/shared-react/src/plugins/usj/TextSpacingPlugin.tsx:216-220), change the wrapped-text branch of the condition so it also requires that previousSibling's text does not already end in a space: $isCharNode(previousSibling) || ($isTextNode(previousSibling) && $isTypedMarkNode(previousSibling.getParent()) && !previousSibling.getTextContent().endsWith(" ")). No other traversal change is needed - node.getPreviousSibling() already finds the right node whether the verse's own parent is the TypedMarkNode or the verse merely follows one; the only missing piece is the guard that stops the transform from re-firing once its own prior insertion is what it now sees as previousSibling. Add a TextSpacingPlugin.test.tsx case with a VerseNode/ImmutableVerseNode as a direct child of a TypedMarkNode (not merely preceded by one) asserting the transform settles to exactly one inserted space instead of throwing. libs/shared-react has no committed dist/ of its own, but packages/platform (which bundles shared-react into its rolled-up build, per packages/platform/dist being one of the two PUBLISHED_PACKAGES in scripts/published-packages.mjs) does, so run pnpm rebuild-committed-output afterward so packages/platform/dist/index.js reflects the fix and node scripts/verify-committed-dist.mjs passes.

How this was checked: Reproduced directly: mounting TextSpacingPlugin over Paragraph > TypedMarkNode > [TextNode("foo"), VerseNode] throws exactly "One or more transforms are endlessly triggering additional transforms", with the stack rooted at $verseNodeTransform's node.insertBefore($createTextNode(" ")) (libs/shared-react/src/plugins/usj/TextSpacingPlugin.tsx:220). The condition at :217-219 checks only node type and parent, never whether previousSibling already ends in a space, so each newly inserted space (itself a TextNode child of the same TypedMarkNode) re-satisfies the same condition on the next pass; Lexical's errorOnInfiniteTransforms trips once infiniteTransformCount > 99 (node_modules/.pnpm/lexical@0.43.0/.../Lexical.dev.mjs:8199), one insertion per pass, matching "about 100 invented spaces." Editor.tsx:478-479's onError(error) { throw error; } rethrows from inside $beginUpdate's catch block before the line that would otherwise restore editor._pendingEditorState = currentEditorState (Lexical.dev.mjs:8862-8875), so the corrupted, unflushed pending state with the fabricated spaces survives for the next update to build on. It's reachable through ordinary use, not just a contrived wrapper: VerseNode extends TextNode (libs/shared/src/nodes/usj/VerseNode.ts:41), so it is not excluded the way marker glyphs are by $wrapSelectionInTypedMarkNode's $isMarkerNode exemption (libs/shared/src/nodes/features/TypedMarkNode.ts:1384-1393), and CommentPlugin.submitAddComment (packages/platform/src/marginal/comments/CommentPlugin.tsx:798) wraps whatever live RangeSelection the user made with no verse-boundary check. This is a full, unrecovered editor crash (React error boundary triggered) reachable by the ordinary "select text spanning a verse, add an inline comment" action, with real document corruption once a later update flushes the fabricated spaces.

Not attached to a line: not on a changed line; cannot be posted inline

#5 (high · checked and confirmed) libs/shared/src/nodes/features/TypedMarkNode.ts:1399 - In Standard and Unformatted views, an annotation whose range starts on a verse number copies the number into the verse's text, so getUsj() and onUsjChange carry {verse 2}, "2 God said", and a settle does not repair it.

What happens: A verse's ['number'] location now resolves inside the verse glyph (markerAndNumberSpans, selection.utils.ts:597-604): offset 0 is offset 3 of \v 2 . $wrapSelectionInTypedMarkNode skips only MarkerNode (1384), so splitText (1407) leaves \v on the VerseNode and moves 2 into the mark as plain text, which is exported after the verse.

Why it matters: getSelection reports a selection that starts on a verse number in exactly this form, and setAnnotation accepts it from any host. paranext-core's comment command refuses such selections, but annotations sent through its editor controller pass unchecked. The previous code refused this location.

Fix: At 1384, test $isGlyphTextNode (from ../usj/glyphPositions.utils.js) instead of $isMarkerNode, and add a calling-context TSDoc. Update the two separator-NBSP note rows in annotationAroundGlyphs.test.tsx and add a verse-number row. Run pnpm rebuild-committed-output.

How this was checked: Reproduced directly in Standard view: annotating from a verse's ['number'] location (offset 0, resolved via markerAndNumberSpans at selection.utils.ts:597-604) into the following text, then reading EditorRef.getUsj(), returns {type:"verse",...,number:"2"} immediately followed by paragraph text "2 God said" — the digit copied into the body — both right after setAnnotation and after a forced settle (blur + commitPendingMarkerEdits()), confirming the settle does not repair it.

Not attached to a line: not on a changed line; cannot be posted inline

#6 (high · checked and confirmed) libs/shared/src/nodes/features/TypedMarkNode.ts:1422 - An annotation over text that contains a milestone (\ts\*, \qt-s |sid="q1"\*) makes the next save drop the milestone from the document, while its marker text stays on screen.

What happens: The range reaches the milestone's AttributeRunNode as an inline element, and Case 3 (1422-1425) appends it whole to the mark; only $isMarkerNode is skipped (1384). The milestone scan needs the run immediately after the MilestoneNode (attributeDisplay.utils.ts:548-575), so the sync reports the run destroyed and pends it, and the settle triggered by the next edit removes the MilestoneNode as a fully deleted run (markerEditTier1.utils.ts:1149-1157).

Why it matters: Until that settle, getUsj() returns \qt-s twice or loses the text after \ts\*; after any edit elsewhere, onUsjChange saves the paragraph without the milestone. The previous code lost it the same way, but this change claims no glyph ever ends up inside a mark.

Fix: In $wrapSelectionInTypedMarkNode, extend the $isMarkerNode(node) check at 1384 to also match $isAttributeRunNode(node) (import from ../usj/AttributeRunNode.js), so an attribute-run wrapper ends the current mark and starts a new one after it instead of being appended whole under Case 3. Add annotationAroundGlyphs.test.tsx rows asserting the saved USJ keeps the milestone after an annotation spans it and a later edit settles, for \qt-s |sid="q1"\* and \ts\*. Run pnpm rebuild-committed-output; packages/platform/dist/index.js bundles this file.

How this was checked: AttributeRunNode.isInline() returns true and it is an ElementNode (AttributeRunNode.ts:176-178), so Case 3 of $wrapSelectionInTypedMarkNode (TypedMarkNode.ts:1422-1425) appends a milestone's whole attribute-run wrapper into the new TypedMarkNode when a selection spans it; the new $isMarkerNode guard at TypedMarkNode.ts:1384 only excludes bare glyph nodes, not the wrapper. I reproduced this directly against the PR's own TypedMarkNode.ts and AttributeRunNode.ts: building a milestone with its wrapped AttributeRunNode (opening/attribute/closing children) and calling $wrapSelectionInTypedMarkNode with a selection spanning it leaves the wrapper as a child of a TypedMarkNode, no longer the milestone's next sibling. That breaks $milestoneAttributeRunPieces's scan (attributeDisplay.utils.ts:553-557), which starts from milestone.getNextSibling() expecting the wrapper there, so every run piece reads absent. The milestone descriptor's deletionPolicy is "remove-owner" (displayRunRegistry.ts:555), and markerEditTier1.utils.ts:1149-1157 calls node.remove() on a fully-absent run, so the MilestoneNode is deleted on the next settle while the visible glyph text (now living inside the mark) is untouched on screen. Case 3 (lines 1422-1425) is pre-existing code the diff did not touch; only the import and the $isMarkerNode block above it are new.

Not attached to a line: not on a changed line; cannot be posted inline

#7 (high · checked and confirmed) libs/shared/src/nodes/features/TypedMarkNode.ts:1440 - In Standard view, setAnnotation over a range that crosses a verse marker, or ends inside a verse number, throws "One or more transforms are endlessly triggering additional transforms", and every later edit stays uncommitted until a flush.

What happens: The glyph skip tests only MarkerNode (1384), so the wrap appends the VerseNode to the mark (1440-1450), or its \v 2 half after a split. $verseNodeTransform (TextSpacingPlugin.tsx:217-220) then inserts a space before the verse on every pass until Lexical throws; the rethrowing onError (Editor.tsx:478-479) leaves the pending state in place.

Why it matters: Any host annotation that spans two verses hits this. The previous code threw the same way for a crossing range; a range that ends in a verse number is newly resolvable.

Fix: Apply the $isGlyphTextNode skip at TypedMarkNode.ts:1384 (imported from ../usj/glyphPositions.utils.js), and add the calling-context TSDoc $wrapSelectionInTypedMarkNode currently lacks. Add rows to annotationAroundGlyphs.test.tsx for both ranges (crossing a verse marker, and ending inside a verse number), and update that file's two existing note rows ("starts at the note's opening marker", "starts at the note's caller"), whose expected annotated text loses its marker-trailing NBSP separators under the same fix. Run pnpm rebuild-committed-output.

How this was checked: Reproduced both named ranges directly: setAnnotation over a range spanning two verses, and a range ending inside a verse's own number, each throws Error: One or more transforms are endlessly triggering additional transforms, from $verseNodeTransform (TextSpacingPlugin.tsx:220) — the verse node lands inside the mark as a sibling of wrapped text whose parent is the mark, which is exactly the condition TextSpacingPlugin.tsx:216-219 inserts a space for, and the newly inserted space re-satisfies its own trigger. A same-update edit to an untouched second paragraph did not itself throw but left that paragraph's text unchanged in getUsj(), matching "every later edit stays uncommitted."

Not attached to a line: not on a changed line; cannot be posted inline

#8 (high · checked and confirmed) libs/shared/src/nodes/features/TypedMarkNode.ts:1427 - An annotation whose range ends in front of, at the start of, or inside a character span or footnote covers that whole span or footnote, glyphs included, instead of stopping where the range ends.

What happens: selection.getNodes() (1365) lists every element the range enters. A marker location resolves to its glyph's offset 0, so an end in front of or inside a span delivers the span as an inline element. Case 3 (1422-1424) makes it the target and appends it whole (1450); the isParentOf check (1380) then skips its children, so the range end is never applied. With \p say the \wj Follow me\wj* and the end at the \wj marker location, the mark covers the \wj Follow me\wj*.

Why it matters: A host range that ends right before a marker, spelled the canonical way, highlights the whole element. The shape predates this change but contradicts its guarantee that no glyph is ever inside a mark.

Fix: Wrap an inline element whole only when the selection covers it entirely; when the boundary lands inside it (a marker glyph offset, or any other partial position), recurse into its children instead of the blanket lastCreatedMarkNode.append(targetNode) at TypedMarkNode.ts:1450. Add a row to TypedMarkNode.test.ts asserting no glyph node sits at any depth inside a typed mark and that a mark's text never extends past the selection's requested end, covering both a range ending at a marker location and one ending mid-way through a char span's own text. $wrapSelectionInTypedMarkNode (TypedMarkNode.ts:1356) currently carries no calling-context TSDoc; since this changes its body, add one noting it is mutating and must be called inside editor.update(). Run pnpm rebuild-committed-output - packages/platform/dist/index.js inlines this file's compiled code, so it needs rebuilding for verify-committed-dist.mjs to stay green.

How this was checked: Built a real editor tree - ParaNode > TextNode("say the ") + CharNode("wj") > [MarkerNode(opening "\wj"), TextNode("Follow me"), MarkerNode(closing "\wj*")] - and called $wrapSelectionInTypedMarkNode with a selection ending at the opening marker's own text offset 0, which is exactly how a UsjMarkerLocation resolves (libs/shared-react/src/plugins/usj/annotation/selection.utils.ts:937-942, "position at the beginning of the opening marker"). selection.getNodes() (TypedMarkNode.ts:1365) returned the whole CharNode as a single entry; Case 3 (1422-1424) set it as the wrap target and lastCreatedMarkNode.append(targetNode) (1450) moved the entire span - both marker glyphs and "Follow me" - into the mark, confirmed by reading editor.getEditorState().toJSON() after the update (root > para > typed-mark > [text "the ", char "wj" > [marker "\wj", text "Follow me", marker "\wj*"]]). The same happens when the end lands mid-span (offset 3 of "Follow me", nowhere near a marker): the isParentOf skip at 1380 then silently drops the later boundary node, so the requested end is never honored - this is a general defect in Case 3, not something limited to marker boundaries. Lines 1427-1450 are unmodified by this PR; only the new $isMarkerNode branch just above them (roughly 1384-1394) is new, and it never gets a chance to run here because Case 3 already swallowed the whole span one iteration earlier. The trigger is ordinary - any annotation/comment whose selection ends inside any styled char span (\wj, \nd, \bd, \it, ...) or footnote, not just at a marker - and it silently over-scopes a shipped, user-facing feature (Comments/Annotations) with no error.

Not attached to a line: not on a changed line; cannot be posted inline

#9 (high · checked and confirmed) packages/platform/src/editor/Editor.tsx:755 - After an annotation is set or removed, a getUsj() right after setUsj(next) returns the previous document, and the editor then loads the previous document instead of next.

What happens: A commit that dirties nodes under a blacklisted tag (setAnnotation, removeAnnotation, a caret-host repair) sets isEditedUsjStaleRef (1227). setUsj replaces editedUsjRef but leaves the flag set (757). The next getUsj() runs refreshEditedUsjIfStale (511-517), which re-serializes the still-loaded old tree over the incoming document; LoadStatePlugin (LoadStatePlugin.tsx:41) then loads that cache. If the reload effect already ran, the screen shows next while every later getUsj() returns the old one.

Why it matters: paranext-core calls setUsj from a passive effect and its save paths read getUsj(), so a save in that window writes the old chapter over the new one. The previous code returned next.

Fix: Clear isEditedUsjStaleRef in setUsj, and keep getUsj() returning the incoming document until its EXTERNAL_USJ_MUTATION_TAG load commits. Add a settledGetUsj.test.tsx row: setAnnotation, then setUsj(next) and getUsj() in one act, expecting next from both reads and on screen. Run pnpm rebuild-committed-output.

How this was checked: Built a reproducing test mounting the real platform Editor: called editor.setAnnotation(...) (Editor.tsx:931, tagged ANNOTATION_CHANGE_TAG), which dirties the tree and sets isEditedUsjStaleRef.current = true via the listener at Editor.tsx:1227. In the same act, editor.setUsj(next) (Editor.tsx:755-766) then editor.getUsj() returned the ORIGINAL document, not next — because setUsj never clears the flag, so refreshEditedUsjIfStale (Editor.tsx:511-517) re-serialized the still-live, not-yet-reloaded tree over the assignment editedUsjRef.current = incomingUsj that setUsj had just made. After letting all effects/microtasks flush (LoadStatePlugin's reload), getUsj() still returned the original document, because LoadStatePlugin reads scriptureRef.current (LoadStatePlugin.tsx:41, wired to editedUsjRef at Editor.tsx:1388) at reload time, and that ref had already been clobbered back to the original by the stale refresh. Both halves reproduce as described above.

Not attached to a line: not on a changed line; cannot be posted inline

#18 (medium · checked and confirmed) libs/shared/src/nodes/usj/markerSeparators.utils.ts:126 - An annotation whose range starts at a character span's opening marker (the \ of \nd) shows two spaces after \nd, and getUsj() and the next save return the span's text with a leading space (" name").

What happens: The wrap skips the \nd glyph (TypedMarkNode.ts:1384) and takes NBSP name whole, separator included. $openerSeparatorGap (126-145) then sees a mark after the glyph and returns "spacer", so a second NBSP is inserted (203). The exporter drops that lone NBSP, and precedesOpeningCharGlyph (editor-usj.adaptor.ts:470-483) no longer sees the glyph in front of NBSP name, so it keeps the separator as a real space.

Why it matters: getSelection() reports the span's own location for a selection that starts on the \. The extra space persists until a marker edit re-tokenizes the paragraph, and the existing "starts at the span's opening marker" row checks only trimmed mark text.

Fix: Make $openerSeparatorGap read through a TypedMarkNode, as $charSeparatorPrefixLength (92-115) already does. Assert getUsj() in that row. Run pnpm rebuild-committed-output.

How this was checked: Reproduced end to end in a headless Lexical probe: mounted a Standard-view editor over {marker:"nd", content:["name"]}, set a selection anchored at offset 0 of the opening glyph and wrapped it via $wrapSelectionInTypedMarkNode (TypedMarkNode.ts:1384 skips the glyph itself and takes the whole " name" text into the new mark). After the real CharNodePlugin transform ran, the char span's children were marker "\nd" | text " " | mark[text " name"] | marker "\nd*" — a second standalone NBSP was inserted at markerSeparators.utils.ts:203 because $openerSeparatorGap (126-145) sees the opener's next sibling is now a TypedMarkNode, never checks $isSeparatorPrefixHostText for it, and falls through to "spacer" at line 144. Calling the mounted editor's getUsj() (routes through precedesOpeningCharGlyph, editor-usj.adaptor.ts:470-483) on that tree returned the span's content as " name" — a literal leading space.

Not attached to a line: not on a changed line; cannot be posted inline

#19 (medium · checked and confirmed) packages/platform/src/editor/adaptors/editor-usj.adaptor.ts:686 - In Standard view, when a double space between two character spans (\w In\w* and \w the\w*) is reduced to one with backspace, getUsj() drops the remaining space, so the words are saved run together while the screen still shows a space.

What happens: A second space between spans displays as two NBSPs through $displayWhitespaceTransform (whitespaceDisplay.plugin.utils.ts:42-66). Backspacing one leaves a lone NBSP that the transform never restores to a space (early return at 44). The exporter drops every text node that is exactly one NBSP (698), and its comment (686-697) assumes content never takes that shape; $shouldIgnoreNodeForContentIndexes (node.utils.ts:999) does the same.

Why it matters: A routine typo fix silently merges words in the file. The previous code behaved identically.

Fix: Restore a plain space in $displayWhitespaceTransform (whitespaceDisplay.plugin.utils.ts:42) when a text node's entire content is a single NBSP, but only when its parent is not a CharNode (mirror the existing $isCharNode(node.getParent()) check the function already uses for the structural-prefix case) — otherwise the fix also rewrites every empty char span's placeholder (EMPTY_CHAR_PLACEHOLDER_TEXT, always a lone untagged NBSP under a CharNode) into one holding a literal space. If instead narrowing the exporter's drop at editor-usj.adaptor.ts:698, keep $shouldIgnoreNodeForContentIndexes (node.utils.ts:999) in sync, as recurseNodes's own header comment requires. Either way, add a Standard-view test that types two spaces between two char spans and backspaces one, asserting getUsj() still returns a single space, plus a regression test that an empty char span still round-trips as empty. Run pnpm rebuild-committed-output afterward — packages/platform carries committed dist.

How this was checked: Built a paragraph with two char spans (\w In\w* and \w the\w*) separated by two spaces in a live headless editor and ran it through the real load, backspace, and export pipeline. Before the backspace, getUsj() (via editorUsjAdaptor.deserializeEditorState) correctly returns "In", " ", "the" as separate content items. Removing one of the two NBSPs (the same character Backspace would remove) leaves the between-spans text node holding exactly one NBSP, verified by reading the node's text content afterward; $displayWhitespaceTransform's early return at whitespaceDisplay.plugin.utils.ts:44 (if (!text.includes(" ")) return;) leaves it untouched since it now contains no plain space character. getUsj() then omits that node entirely under the serializedTextNode.text !== NBSP guard at editor-usj.adaptor.ts:698, so the exported para content becomes "In" and "the" back-to-back with no separator between them, while the live document still shows the NBSP on screen. $shouldIgnoreNodeForContentIndexes at node.utils.ts:999 applies the identical NBSP-drop test, so the logical position model agrees with the corrupted export instead of catching it. The guard and its surrounding comment at editor-usj.adaptor.ts:686-698 predate this PR (git blame: commits a0d0936 and d5412cb, both ancestors of the merge base) — this is a real, reachable, pre-existing defect the PR did not introduce or touch at this exact site, even though it restructured the surrounding function.

Not attached to a line: not on a changed line; cannot be posted inline

#24 (medium · checked and confirmed) packages/platform/src/editor/markerEdit/tier2Rebuild.utils.ts:2176 - Clicking into a footnote's text after leaving a half-typed marker (for example \wj) elsewhere in the same paragraph moves the caret to the start of the paragraph's \p marker instead of where the user clicked.

What happens: Moving off the pending literal runs $rebuildParas. The caret is inside the note, so anchorInParas is true (2241-2245), but $caretSpanByteAnchor(combined, ...) (2247) returns undefined: the note is one sentinel span keyed by the NoteNode, and $walkToCaret (1322-1347) never matches it. $restoreSelectionAtOffset then calls selectStart() on the new paragraph (2176-2178), even though the note was carried across intact.

Why it matters: The next keystrokes edit the \p glyph instead of the footnote, wherever the caret can enter a note. The previous code did the same.

Fix: When the collapsed caret is inside a combined.sentinels node, keep the selection instead of calling $restoreSelectionAtOffset. Add a row that settles a paragraph with the caret in a note's text. Run pnpm rebuild-committed-output.

How this was checked: Reproduced with a real test run: a paragraph with an expanded note plus a \nd x\nd* typed span (same fixture shape as tier2Rebuild.utils.test.tsx:406-409), caret parked on a text node inside the note, then $rebuildParas([para], context). $buildParaScopeFragment folds the whole NoteNode into one sentinel span keyed to the note's own key (tier2Rebuild.utils.ts:1014-1016, 442), so $walkToCaret (tier2Rebuild.utils.ts:1330-1346) never matches the inner text node's key and $caretSpanByteAnchor (1349-1366) returns undefined. $restoreSelectionAtOffset then takes its anchor === undefined branch (2176-2178) and calls selectStart() on the rebuilt paragraph. After the run the selection anchor was the paragraph's \p MarkerNode at offset 0, while the note's original text node was still attached, unchanged, holding the same content. $restoreSelectionAtOffset's body is byte-identical between the merge base and HEAD, so the anchor sits on unchanged code.

Not attached to a line: not on a changed line; cannot be posted inline (ambiguous: excerpt also matches at line(s) [2196]; nearest to 2176 chosen)

(AI-assisted, with my guidance)

lyonsil added a commit to lyonsil/pr-review that referenced this pull request Sep 24, 2026
Linking each node_modules directory whole resolved pnpm's relative
workspace links (node_modules/shared -> ../libs/shared) against the
primary checkout, so every test run in the worktree imported the
primary's workspace source instead of the head under review. Refuters
on paranext/scripture-editors#11 hit this on every platform test run,
several fell back to reading code, and one refuted a real defect.

node_modules is now a real directory mirrored entry by entry: an entry
resolving to a tracked path outside any node_modules points at the same
path in the worktree, while installed packages, .bin and the .pnpm
store are still shared. Caches tools write into node_modules stay in
the worktree. The refuter brief and /pr-review step 8 say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tjcouch-sil and others added 20 commits September 25, 2026 10:10
… once per commit, in order

onUsjChange now carries getUsj()'s settled document, emitted synchronously
from ONE update listener per content commit; the ops stay live. The
listener is registered once (layout effect, stable callback reading its
inputs through a ref) so a setUsj reload can no longer reorder emitters
and double-announce, and it brings the pend set current itself for an
undo/redo before settling, whichever listener runs first.

- shared: the pended-owners registry carries the engine's re-derivation
  (registerPendedDisplayOwners' `rederive`) and
  ensurePendedDisplayOwnersCurrent runs it once per history-restored
  state; MarkerEditPlugin's historic branch calls it too.
- shared-react: export $getUpdateOps and extract shouldSkipUpdateForOps
  from DeltaOnChangePlugin, which keeps using both.
- Editor: the listener replaces DeltaOnChangePlugin's emission, the
  settled-leg microtask notifier and the stale-cache listener; applyUpdate
  announces readSettledUsj(); readSettledUsj is memoized on the editor
  state, sorted pend keys, transient input, last caret key+offset, view
  options and marker lookup.
- The listener is mounted through a small inner plugin, because the
  Editor's own layout effect runs before EditorRefPlugin sets editorRef.
- A load seeds the "last announced" yardstick, and ops are chopped (a
  text node marked dirty without change yields a lone trailing retain):
  without both, a commit that changes no bytes announced the unchanged
  loaded document.
- onUsjChange / MarginalProps.onUsjChange / setTransientInput TSDoc state
  usj SETTLED and ops, insertedNodeKey LIVE.

Test corrections to the plan's onUsjChangeSettled suite: wCharOf is typed
MarkerObject & { lemma?: string } (the verbatim file failed typecheck with
TS2339); its bare discrete commits run inside a synchronous act (the
toolbar's undo-state update otherwise warned), which re-verified RED for
tests 1-4 with the same four reasons; and a seventh test pins that a
commit changing no bytes, after the first load or a reload, emits nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Update exactly once

The change listener refreshed its tree-leg cache only when chopped ops
survived. A commit can change the document without changing any text:
retagging a one-text paragraph in the formatted view (formatPara via
$setBlocksType) moves its only child into a new paragraph node, so the one
dirty leaf reads the same and the delta is a lone retain (or nothing, when
the paragraph is the document's first node). The cache stayed stale,
getUsj() kept the old marker and nothing was announced. The cache is now
refreshed on every commit the ops filter does not skip; the chopped ops
still drive the payload and the ops-always-announce rule.

applyUpdate(ops, "local") was announced twice, by the change listener
(delta-doc coordinates) and by applyUpdate itself (the caller's ops,
apply coordinates). The listener now stands down while applyUpdate
commits a local apply, so applyUpdate's announcement is the only one. A
flag around the discrete commit rather than an update tag, because a tag
on an update that dirties nothing stays on the editor and would silence
the user's next edit.

Tests: formatPara on a single-text paragraph in the formatted view (both
after other content and at the document's start) announces once and
getUsj() carries the new marker; a local applyUpdate inserting a note
announces once, with the caller's ops, source "local" and the note's key.
All three were RED before the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
selectNote's index counted live NoteNode order, so with a note literal
still pending it could select the wrong note. When something is
pending and the caller passed an index, count the notes of the
settled document (getUsj()) instead, and carry that note's location
to the live tree: onto the real NoteNode when it is already settled,
or a collapsed caret at the pending literal's own `\` when it is not.
A key still resolves directly against the live tree, as does an
index once nothing is pending.

Kept building the note's jsonPath through usjJsonPathFromIndexes
(accumulating numeric indexes) rather than a hand-built string, so it
returns the library's ContentJsonPath type without an `as` cast.

Documents selectNote's index as SETTLED and its key as LIVE, and
getNoteOps as LIVE for both, per the position contract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… layout

R1: the block verse layout now participates in the same position contract as
every inline layout instead of refusing. getSelection/setSelection/
setAnnotation/onSelectionChange translate through the new
blockVerseLocations.utils (`$blockToUsj`/`$usjToBlock`/`$getBlockUnits`)
whenever the tree has verse blocks, mapping a location's leading indexes
between the USJ and the regrouped block tree. A paragraph fragment is stamped
with the root-child index it was cut from (`verseBlockSourceState`) so
fragments of one source can be told apart from a fresh one. `$endsLine` also
now treats a verse block's own children as line ends, fixing a pre-existing
gap where a table inside a verse block wrongly ended at the next paragraph.
The block-verse refusal path (`reportUsjLocationsUnavailable`) and its
now-unused paragraph/annotation guards are removed; applyUpdate's block-verse
guard is untouched (unrelated - it addresses content by live position, which
the regrouping still breaks).

Corrections to tests that assumed the old refusal, per validation:
- selection.utils.test.ts's "block verse layout" describe block asserted
  `undefined`/refusal; rewritten to assert parity with the inline layout,
  which is the new, correct behavior.
- block-verse-guards.test.tsx's shared it.each for getSelection/setSelection/
  setAnnotation asserted the same refusal; replaced with two tests asserting
  each API now resolves instead.
- blockVersePositions.test.tsx's table-end test used a substring match on the
  node type ("immutable-chapter" contains "table") that matched the wrong
  node before ever reaching the real table; narrowed to the exact type
  predicate, which reproduces the intended RED (block/inline disagree) rather
  than a harness error.
- blockVersePositions.test.tsx's older-spelling sweep hit one point where the
  inline layout's own pre-existing TypedMarkNode-interior-point quirk (already
  excluded from the point oracle) applies but the block layout's fragmenting
  happens to avoid it; excluded for the same reason the oracle excludes it.

Bug found during validation: `$usjToBlock`'s older `{ container, offset }`
branch rejected (returned `undefined`) an offset past a source's last
fragment's own logical item count, instead of clamping to its end the way
`$contentEndLocation` does for every other out-of-range index inline. A
paragraph split across a verse boundary, with a comment milestone consumed
into the surrounding coalesced text run, can leave the fragment's logical
count short of the raw USJ item count that produced the offset. Fixed to
clamp instead of refuse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every EditorRef method and EditorProps callback that takes or returns a
position, an index, a node key, ops, or USJ now leads its doc with a
**Settled**/**Live** label plus a one-sentence meaning, reusing the same
two sentences everywhere so the shape is grep-able across the file.
getSelection's contract is restated as the one rule: a position names the
byte in front of which it sits and maps through whatever contains that
byte on its own side, snapping LEFT in both directions with no separate
outbound-snap/inbound-refuse split.

docs/standard-view-invariants.md's location section states the same rule,
replacing the old deliberately-asymmetric outbound/inbound split, the
partial-pairing whole-scope refusal, and the stale-basis-as-refusal text
with the unified aligner description, and adds onUsjChange's
settled+synchronous contract, the notes-by-index rule, typed table cells
under ParatextData IsCell parity, and the \c/\tr/\esb/\esbe space and
`\c 2 made` non-changes as recorded, not-a-defect behavior.

Comment hygiene: dropped the backward-facing "Target: .../ Probe-run
against SE 41e6747" header from onUsjChangeSettled.test.tsx — the rest of
the branch diff (both repos) was swept for the same pattern and came back
clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… the announced document from the tree

Final whole-branch review fixes:

- usfmByteAlignment: an attribute section (`|...`) now ends at the next `\`
  OR the next placeholder. A plain-text `|` followed in the same stretch by a
  typed note literal let the settled side's section run past the placeholder,
  pairing the literal's `\` with a later marker's `\` as "same" and snapping
  positions after it to the RIGHT. New aligner row pins the probe
  (live `a|b\f+\ftx\f*more\wz\w*` vs settled `a|b<placeholder>more\wz\w*`).
- Editor: the load commit seeds `lastNotifiedUsjRef` with the tree's own
  export instead of the host's raw USJ, so a lossy round trip (a double space
  collapses) is not announced back through `onUsjChange` by the next
  element-only commit. New onUsjChangeSettled rows: lossless control + lossy
  (double space) fixture, paragraph markDirty after load emits nothing.
- settledPositions / standard-view-invariants: drop the stale "or the layout
  has no USJ locations at all" reason for an undefined selection (the block
  verse layout reports positions); point "one aligner" at
  markerEdit/usfmByteAlignment.utils.ts.
- onUsjChange contract (invariants doc + EditorProps TSDoc): once per commit
  that changes the settled document or carries ops; annotation/cursor/
  selection commits and the read-only block verse layout emit nothing.
- shared-react selection.utils: delete the unused
  $usjLocationFromBlockPoint / $blockPointFromUsjLocation exports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a mark

Lexical's selection.getNodes() lists an entered element before its children, so a range covering
a whole AttributeRunNode wrapper handed the wrapper itself to $wrapSelectionInTypedMarkNode's
inline-element case, which moved it into the mark; the attribute-text guard never saw its
children. A verse, being a TextNode, was split and wrapped like content.

- `\qt-s |who="Pilate"\*`: the wrapper moved into a mark, the display-run sync then found no run
  beside the milestone, and its remove-owner deletion policy dropped the milestone from getUsj().
- `\v 2 \va 3\va*`: the verse moved into the mark after wrapped text, and TextSpacingPlugin's
  verse transform inserted a space before it on every pass until Lexical threw "transforms are
  endlessly triggering".

Both reproduce through CommentPlugin and AnnotationPlugin (setAnnotation), which wrap through the
same function. A verse, a milestone, any AttributeRunNode and anything inside one now end the
current mark like a marker glyph does, and later content starts a new mark with the same id, so
an owner and its run always stay together outside the mark.

The test for a range starting inside the attribute value passed before the fix (the existing
glyph guard already covered it); it stays as a pin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ep a note caller with its category run

A comment across a verse now wraps without throwing, which exposed TextSpacingPlugin's verse
transform: it inserted a structural space before a verse whenever the text in front of it sat in
a TypedMarkNode, even when that text already ended in a space, as canonical text before a verse
does. `["in the beginning ", \v 2, "and the earth"]` with a comment across the verse read back
from getUsj() as "in the beginning  ", and the next content commit carried it to the host. The
transform now skips the insert when the wrapped text already ends in a space or NBSP, the rule
$addTrailingSpace applies to bare text.

A comment starting at a note's editable caller moved the caller into the mark and left its `\cat`
AttributeRunNode outside it. $noteCategoryRunPieces scans the caller's next sibling, found no run
inside the mark, and the next edit dirtying the note wrote a second `\cat cat1\cat*`. A chapter's
`\c N` glyph text had the same gap with its `\ca`/`\cp` runs. The wrap now treats the text an
element owner's run is anchored after (the note caller, the chapter glyph) as part of the unit,
so anchor and wrapper stay together outside the mark.

The platform verse test now pins the whole settled paragraph (comment milestones aside) rather
than the verse item alone, and is titled for what it asserts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es/ cycle-free

Keeping a note's caller and a chapter's glyph with their runs made TypedMarkNode.ts import
attributeDisplay.utils.ts, which already imports $isTypedMarkNode from TypedMarkNode.ts: a runtime
import cycle under libs/shared/src/nodes, where every cycle had been removed.

$wrapSelectionInTypedMarkNode and its private predicates ($isAttributeDisplayRun,
$isElementOwnerRunAnchor, $isDisplayOwnerUnit) move unchanged into
nodes/features/typedMarkWrap.utils.ts, which imports both modules; nothing imports it back. The
features barrel re-exports it, so the package surface is unchanged and every caller that imports
it from "shared" is untouched. The TypedMarkNode test imports it from the new module.

The element-owner anchor docstring now says the runs are found beside the anchor: a chapter's \cp
run follows its \ca run when there is one, not the glyph itself.

Verified with a Tarjan SCC over runtime (non-type-only) relative imports in libs/shared/src: no
strongly connected component touches nodes/ (the one TypedMarkNode <-> attributeDisplay.utils
component present before this change is gone).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A caret on a paragraph element point (a click past a trailing note, or a
boundary in front of a note) had no span of its own, so the settle's caret
capture recorded no anchor and the restore fell back to the paragraph's
start (`\p` offset 0) whenever a sibling literal in the same paragraph
settled.

The capture now spells an element point from the child bytes beside it,
through the same `$anchorForPoint` the settled-position translation uses
(moved beside `$caretSpanByteAnchor` so both layers share it); the
existing restore then places it after the note / at the same boundary.

The paragraph-start row passes before and after: Lexical normalizes an
element point at offset 0 onto the `\p` glyph's text, so it is a
regression guard rather than a RED row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t after a load

The load commit seeded the announced-document yardstick with the tree's
export, but left the cache `getUsj()` hands back (when nothing is
pending) holding the host's raw USJ. In Standard view the export
collapses a run of spaces, so after loading "In the  beginning" getUsj()
returned the double space until the first commit refreshed the cache,
then one space, with no onUsjChange in between.

The load commit now seeds the cache from the same export, falling back
to the host's USJ only when there is no export (the block verse layout).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e next edit

Lexical clears an update's tags only when its commit dirties a node, so a
remote applyUpdate whose ops the tree already reflects (retain-only, or an
op replayed twice) that commits only a caret the DOM moved left
DELTA_CHANGE_TAG on the editor. The tag rode onto the user's next
keystroke, and the change listener took that commit for a remote apply:
the typed character was displayed but never announced or saved until the
following edit.

The change listener now recognizes an apply of either source by the flag
applyUpdate holds around its discrete commit, the same way it already
recognized a local apply, and leaves DELTA_CHANGE_TAG out of the tags that
keep a commit from being announced or from yielding ops. The remote apply
is still tagged for the marker-edit engine and the display syncs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t can outlive its commit

DELTA_CHANGE_TAG stays on the editor after a remote applyUpdate whose
commit dirties nothing (Lexical clears tags only for a commit that dirties
a node), so it rode onto the user's next commit. Beyond the change
listener, two readers took that commit for a collaborator's: the display-
run sync skipped its destroyed-run detection, so a run the user deleted
with the caret elsewhere was rewritten from the owner (the deletion
silently undone), and the marker-edit engine's destroyed-owner listener
skipped its pend.

An editor-scoped registry in shared (`markApplyingUpdate` /
`getApplyingUpdateSource`) now carries the apply's source for exactly its
own discrete commit. applyUpdate sets it in the same try/finally as
before; the change listener, `$runDestroyedSinceLastCommit` and
`$pendOwnersOfDestroyed` read it (the two display-run readers for a
"remote" source only, as the tag meant). The tag is still added for
external consumers; nothing in platform reads it any more. The registry
carries the source rather than a boolean because a local apply is the
user's own edit and must still count as one for the display-run readers.

applyUpdate called inside an update of the same editor commits after the
signal is cleared and is announced as a local edit without the caller's
ops; its TSDoc now forbids that, and the call logs an error (and still
applies).

The suites that simulated a remote apply by tagging an update directly
(whitespacePreservation, charAttributeDeletionSettle) now also mark the
commit through the registry, as applyUpdate does.

Also: a comment on the offset-0 element-point row, an ops assertion in
the no-op remote apply test, and import order in settleCaretCarry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he child it names

An element point between two of a comment mark's children (e.g. in front of a
verse the mark spans) snapped to the mark's own front edge, reporting the end
of the text before the mark instead of the verse — an exactly representable
position reported left of where it is. It now resolves to the location in
front of the child at that offset, through the new shared
`$getLogicalPointBeforeNode`; only the mark's own edges use the mark's
position.

Before: {key: mark, offset: 1} in front of verse 3 reported
{jsonPath: "$.content[0].content[1]", offset: 17}; now the verse's
{jsonPath: "$.content[0].content[2]"}. The block-verse oracle no longer
excludes element points inside a mark, and its older-spelling sweep drops
the `$.content[5]` offset 2 exclusion: both layouts now agree there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A typed note literal `\+add*\nd*\f*` chains three touching closers. Byte 0
of `\nd*` (past `\+add*`) and byte 0 of `\f*` (past two closers) each report
the position just past the closer directly before them — the nd and note
closers at `closingMarkerOffset: 0` — which is what an editor loaded with
the settled document reports for the same caret.

Both rows pass without a code change: `$afterPrecedingClosingGlyph` only
needs the ONE closer before the target, since the target itself is a closer
at offset 0. Disabling that helper turns the `\nd*` row red; the `\f*` row
is answered by caret addressing either way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ebar and a root-level char span

Two contract scenarios for the scope-end path in `$scratchPointLocation`,
which only the mid-document `typed-sidebar` exercised:

- `typed-sidebar-last`: the same `\esb \p side \esbe` typed into the
  document's last paragraph, so the settle scope is the last root item.
- `typed-char-at-scope-end`: `In the \c 2 \nd LORD\nd*`, whose closed `\nd`
  span settles to a root item and is the scope's last.

Both map every position without a code change. Dropping the scope-end
branch of `$scratchPointLocation` (spelling the boundary as the scratch's
document end) turns both red, alongside `typed-sidebar`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…aph's separator; resolve a backward cross-paragraph selection

Two defects of a comment wrapped across a paragraph boundary, reproduced with
nothing but text in the range:

- The wrap moved the next paragraph's engine-owned separator (the NBSP after
  its `\p` glyph) into the mark. The glyph then read as missing its separator
  and got a second one, so the display showed `\p` + NBSP + NBSP. The wrap
  now treats a marker-trailing separator like the glyph it belongs to:
  display bytes, never annotated content. (Before: annotated
  ["charlie", " depart"], display "\p  depart here".)
  annotationAroundGlyphs' two note rows now expect the mark without the
  separators on either side of `\ft ... \ft*`, which it no longer takes.

- A caret on presentation text that opens a mark (the backward wrap's
  `selectStart` landed on that separator) overflowed the stack:
  `$locationFromNode` sent the text to `$locationBeside`, which asked the mark
  for its front element point, which asked the same text again.
  `$locationBeside` now steps out through a mark itself — beside the mark at
  its edges, in front of the next content between its children — so a real
  caret there always has a location. (Before: RangeError, maximum call stack
  size exceeded, for a backward selection anchored at such a separator.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e child a mark's interior point falls back to

- The comment wrap uses the exported `$isMarkerTrailingSeparator` instead of
  a local copy. The comments now name the set that tag covers: the separator
  after a `\p`, `\tr` or `\tc` glyph and a collapsed note's layout
  separators. A char span opener's separator is never tagged.
- An interior element point on a mark with no content from there to the
  mark's end reports the mark's BACK edge. Before: `{mark, 1}` on
  `["abc", <line break>]` reported offset 0, in front of "abc"; now offset 3.
- `$getLogicalPointBeforeNode` answers `undefined` for a node that neither
  is nor holds any of its logical parent's items, so a nested mark holding
  only presentation is skipped. Before: `{mark, 1}` in front of such a mark,
  followed by a verse, reported the end of the paragraph
  (`$.content[0].content[2]` offset 3); now it reports the verse
  (`$.content[0].content[1]`).
- `$locationFromLogicalPoint` is the one place a logical point becomes a
  location, shared by the element branch and the mark-interior helper.
- A mounted row pins the stack-overflow fix end to end. A comment starting at
  `\nd` over a nested `\+wj` opens on the span's untagged NBSP spacer; a
  backward selection across the paragraph boundary, focused there, reports
  both ends. It overflows the stack without the `$locationBeside` mark step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tjcouch-sil and others added 4 commits October 20, 2026 10:11
…r as no byte

USFM writes a line's last verse number followed by the newline, with no separator, and the browser does not draw the glyph's trailing separator at a line end (End, a click past the line and ArrowRight all put the caret right after the number). Locations now follow the file:

- every caret after a line-final number, the last line's included, and any spaces typed there, report ['number'] at the number's length; a line-final number no longer reports the document end
- the number's length lands right after the number again, where End puts the caret
- one past it on a line that is not the last lands where the next line's marker location does
- the document end lands at the end of the last line, in front of such a separator

Chapter lines are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or verse number

Deleting a char marker at a paragraph's start (`\p \nd \w a\w* b\nd* z`)
leaves its separator on screen as the text's own space, and getUsj() saved
it: `\p  \w a\w* b z`. Typed spaces after `\p` or after `\v 2 ` were saved
the same way. ParatextData's tokenizer skips all whitespace after a
non-closing marker (UsfmToken.Tokenize, and GetNextWord after a verse or
chapter number), so it never loads such a space and normalizes it away on
save.

In Standard view getUsj() now drops plain spaces at the start of a
paragraph's content (any para marker, and the root's implied paragraph)
and at the start of a text right after a verse number, dropping a text
that held nothing else. Not after a verse's \va/\vp or a chapter's
\ca/\cp: there the text follows a closing marker and ParatextData keeps
the space. A data NBSP (`~`) stays. The screen keeps what was typed.

The logical position model drops the same characters through the
per-segment mapping the space-run collapse uses, and removes a text that
held only such spaces from the item list, so locations index the saved
USJ: $getLogicalIndexOfChild/$getJsonPathIndexes/
$getElementPointFromLogicalIndex now take the view's collapsesSpaceRuns
flag.

Tests that pinned a saved paragraph-leading space are updated to the new
rule; the corpus round-trip row for it skips the two Standard views.

Trailing paragraph whitespace is deliberately not dropped: ParatextData
keeps one trailing space before an unknown marker's paragraph (it is an
inline marker in USFM), so dropping it needs the project stylesheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…own task

"crosses the note whole again on the backward press" awaited the ArrowLeft press in an async act and then read the selection. The press's DOM selection write queues a selectionchange that jsdom delivers on setTimeout(0), while the async act resolves on setImmediate, so under load that event sometimes ran first. The caret-host guard then removed the host the caret had left; that commit's DOM selection already matched, so Lexical focused the root element, which the test editor never had focus on, and jsdom's focus() collapses the selection to (root, 0). The next selectionchange carried the caret to the paragraph's start, and the row failed (1-2 of 32-64 runs at 16-way concurrency; draining the queued events after the press fails it every time).

Not reachable by a user: a key press reaches a focused editor, so Lexical never takes that focus() branch, and the press itself lands correctly in both orders.

The press is now dispatched in a synchronous act and read with editor.read() in the same task, so no queued selectionchange can run between them. 32 sequential, 8 concurrent and 64 16-way concurrent runs pass; making the backward visible-stop move land inside a collapsed note fails only this row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tjcouch-sil added a commit that referenced this pull request Oct 20, 2026
…pt-4370-unified-annotations

Brings in #11's head 02f8a1a (main's PT-4611 settle suspension, the
line-final verse separator ruling, spaces after a paragraph marker or
verse number left unsaved, the deleted-glyph spacer, the protected
closer's first Backspace). Resolutions:

- NoteShellCaretGuardPlugin: both sides fixed Backspace at a protected
  closer's front; kept this branch's, which checks the caret is on the
  closer itself and goes on from the content end, with #11's docblock.
- selection.utils: #11's ruling (one past a line-final verse number is
  the next line's start; `$nextLineStart`, `$endsDocument` gone) read at
  the verse's last piece; `$endsItsLine` reads through marks and pieces;
  `$locationOfFirstByteAfter` keeps the mark-transparent parent;
  `$locationBeside` keeps the piece-aware index with #11's
  `collapsesSpaceRuns` path.
- node.utils: item continuations stay skipped beside #11's implied-para
  splice and leading-space drop; `$measureTextItem` drops the leading
  spaces across nodes and collapses a run an annotation's edge cuts as
  one; `$getLogicalIndexOfChild` takes #11's parameter and the carrier's
  head.
- MarkerEditPlugin: #11's blurred-settle path, with the caret rested
  through marks before the settle.
- markerEditTier1: #11's caret-in-rest rule, the caret measured across
  the verse's pieces and the glyph rewritten through `$setLogicalText`.
- whitespaceDisplay: #11's first-in-span rule, read through marks.
- editor-usj.adaptor: the implied paragraph's text joins the root's
  through the Standard-view run collapse.
- Tests: spaceRuns keeps both describe blocks; noteShellProtection and
  canonicalLocations take #11's wording and ruling, with this branch's
  cut-verse caret test rewritten to the new ruling; the logical-model
  oracle drops leading spaces and continues runs across nodes; two
  node-utils calls pass the new parameter. dist: ours, rebuilt next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tjcouch-sil and others added 8 commits October 21, 2026 10:39
# Conflicts:
#	packages/platform/dist/index.js
#	packages/platform/dist/index.js.map
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ctionchange fires

"reports a caret placed at a text boundary exactly once" failed on macOS CI
with an intermittent miss. A boundary caret is reported by Lexical's own
selectionchange listener, and jsdom fires that event from a setTimeout(0).
React's async act waits with setImmediate, and Node does not order the two,
so act could return before the event fired.

The test now drains queued events after mounting (so a late mount event
cannot add a call after the clear) and after the placement (so the count
includes the report), as noteCaretPlacement.test.tsx does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… splits a span in front of it

In a view without marker glyphs, Enter inside `\w ab~cd\w*` right in front
of the `~` saved `\w cd\w*` for the second paragraph: the author's NBSP was
lost.

Enter cuts the text node in two, and the `~cd` half is a node the update
created, in a span the update created. The placeholder transform dropped a
leading NBSP from such a text unless the node itself held content before
the update, which a split's new half never did. A caret still at the start
of that text means nothing was typed into it, so it is moved text and keeps
its byte. Main had the same caret-at-start guard; it went missing when the
new-span case was split out.

No other test reaches this new-span drop (checked by disabling it: both
suites stay green apart from the new test).

Tests: CharNodePlugin.test.tsx, Enter in front of an author's `~` keeps it
(RED: `cd`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at location back

At the end of a paragraph, Space after `\v 2` and then a selection restore
(paranext-core saves the caret as focus leaves and hands it back when the
marker palette or a popover closes with the editor's selection lost) put
the caret in front of the typed space, so the next letter made verse
number `2t`.

The spaces typed after a line-final verse number are no bytes of their own,
so a caret among them reports the number's end, and setSelection put that
location right after the number. setSelection now first tries the last
caret the editor observed: if its text is still in the document and a caret
there reports exactly the requested location, that caret is put back.
Otherwise the location resolves as before, so the number's end still lands
right after the number when the user's caret was elsewhere, and
getSelection reads back what was set either way.

Tests (canonicalLocations.test.tsx): a restored caret among typed spaces
stays there and the next letter is verse text (RED: number `2t`); with the
last caret elsewhere the number's end lands right after the number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions

# Conflicts:
#	packages/platform/dist/index.d.ts
#	packages/platform/dist/index.js
#	packages/platform/dist/index.js.map
#	packages/platform/src/editor/editor.model.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@katherinejensen00 katherinejensen00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:lgtm:

@katherinejensen00 made 1 comment.
Reviewable status: 180 of 219 files reviewed, all discussions resolved (waiting on lyonsil).

@katherinejensen00
katherinejensen00 merged commit 6cfdfae into main Oct 21, 2026
5 of 6 checks passed
@katherinejensen00
katherinejensen00 deleted the pt-4370-settled-positions branch October 21, 2026 21:40
tjcouch-sil added a commit that referenced this pull request Oct 22, 2026
Only the committed utilities dist conflicted; rebuilt from the merged source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tjcouch-sil added a commit that referenced this pull request Oct 22, 2026
SE #11 fixed the leading-NBSP strip in a char span, the double-\ca fold in
the tokenizer, and the nameless-marker converter bug, so their pins flipped
as designed. The 2SA-5 .usj no longer needs a hand edit: the converter now
writes exactly what it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
timothy-mccormack added a commit to paranext/paranext-core that referenced this pull request Oct 23, 2026
…2900)

In Simple mode, saving a new comment kept the comment popover open for
7-9 s while the editor waited to select the new thread in the Column 3
Comments tab, which rc-dock has not mounted until it is first shown. Save
now closes as soon as the comment is saved, and the selection is
best-effort. Clicking a comment's highlight in Simple mode now brings the
Column 3 Comments tab to the front with that comment selected instead of
opening a second Comments column. Power mode is unchanged.

legacyCommentManager.selectCommentThreadInPanel takes a new optional
editorWebViewId; when the panel shows a different project, it is reloaded
onto the editor's project (via reloadWebView options) before selecting.
Save and highlight click both pass it.

The "Invalid selection" item in PT-4809 was fixed in the editor by
paranext/scripture-editors#11 (PT-4370).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tjcouch-sil added a commit that referenced this pull request Oct 26, 2026
… in every view

ParatextData reads `\f \ft x\f*` (one or two spaces after the marker) as
caller="" and writes it back as `\f \ft x\f*` (measured with the corpus
capture harness), and the tokenizer has followed that since PR #11. The
existing rows covered only the expanded editable views; this pins a
load, an edit beside the note and one inside it, and a departure in
every editor view, saving caller "".

Fails in all 8 views if the exporter writes "+" for an empty caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz
tjcouch-sil added a commit that referenced this pull request Nov 3, 2026
…rpus (#29)

* feat(utilities): let usjToUsxString stamp a caller-specified USX version

usjToUsxString(usj, { usxVersion }) stamps the requested version on the
root element instead of always writing USX_VERSION. USJ cannot carry the
source USX version (the editor regenerates usj.version everywhere), so a
host that needs its USX back at the version ParatextData served it must
say so explicitly. Default output is byte-identical to before.

Part of the PT-4197 data-fidelity audit; lets paranext-core drop its
post-hoc version="3.1" string replacement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: composed PDP round trip, USX-first net, corpus extension (PT-4197)

Pins the full load/save trip over the real per-chapter USX the paranext-core
PDP produces (vendored under testUsfmCorpus/pdp-usx/):

- corpus-pdp-usx-composed-round-trip: USX -> USJ -> mounted editor -> USJ ->
  USX per capture, in Standard, formatted and paragraph-structure views, with
  a pinned expected-divergence list (an unlisted divergence fails; a listed
  one that stops happening fails).
- testusfm-usx-usj-usx-round-trip: the USX-first converter net, so an
  asymmetric usx<->usj pair cannot hide from both directions.
- corpus-testusfm-round-trip and corpus-transform-fixed-point now run every
  fixture in all three editing views (they were Standard-only).
- New fixtures testUSFM-2SA-4 (NBSP shapes, verse bridges/segments, \fe, \x,
  \cl/\cd, callers, \ref), testUSFM-2SA-5 (damaged-but-at-rest: nameless
  markers, double \ca, chapter-line text) and testUSFM-1SA-1 (book intro),
  with .usj derived by the documented recipe (provider USX + usxStringToUsj;
  2SA-5 hand-corrected to the intended marker:"" shape).

Known losses are routed through each suite's expected-failure mechanism,
attributed to scripture-editors #11, #19 or PT-4391, so a merge that fixes
one flips the pin visibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: drop the pins SE #11 fixed (PT-4197)

SE #11 fixed the leading-NBSP strip in a char span, the double-\ca fold in
the tokenizer, and the nameless-marker converter bug, so their pins flipped
as designed. The 2SA-5 .usj no longer needs a hand edit: the converter now
writes exactly what it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep an authored lone-NBSP content string outside Standard view (PT-4391)

Outside Standard view a data NBSP is held as a literal NBSP, so a content
string that is exactly one NBSP (`\nd ~\nd*`, `\p ~`, a `~` between two
spans or after a note) was byte-identical to the engine's lone-NBSP display
nodes and the editor->USJ conversion dropped it, already at load.

The conversion now drops a lone NBSP only in the slots the engine's display
nodes occupy: right after an editable glyph, right after a read-only glyph
inside a span, first in a span or note, after a collapsed note's caller or
span, or inside an attribute run (tagged/token nodes and the mark cases are
unchanged). The one slot an author's `~` shares with a display node - a
span's first content in the marker modes without a separator, where the
empty-span placeholder sits - is disambiguated by a new node state,
`authoredNbspState`, that the forward adaptor's createChar sets.

The same rule is the live `$isLoneNbspContent` (node.utils.ts), used by the
logical content model and the Tier-2 fragment flattening so annotation
positions and settles agree with the export. CharNodePlugin no longer treats
an authored `~` as an empty span's placeholder when text is typed beside it.

Flips the pinned drops: constructed-usj-round-trip's paragraph-leading lone
NBSP, the 2SA-4 entries in the composed PDP round trip and the testUSFM
round trip (adaptor and transform legs), the formatted-view leading-NBSP pin,
and the logical-model spacer pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: one shared registry of editor views and one mountInView (PT-4197)

Every per-view list the editor suites iterate is now a projection of
EDITOR_VIEWS in editorViews.test-helpers.tsx: the location oracle's
ORACLE_VIEWS/oracleView, the corpus suites' ROUND_TRIP_VIEW_CONFIGS and
EDITING_VIEW_CONFIGS (moved out of corpus-data.ts, which is compiled
with the library and so cannot import a test helper), and the new
EDITING_VIEWS (standard, formatted, paragraph-structure).

The two private Editor mounts (settledGetUsj's mountEditor and the
location oracle's mountInView) merge into one mountInView(usj, view,
opts); mountStandardViewEditor/mountExpandedNoteEditor are thin
wrappers. Also adds footnotePopoverView(host) (mirrors paranext-core's
FootnoteEditor options), forEachView, knownFailureRunner, and
noteEditorRef's optionsInView(view).

The Standard-with-expanded-notes round-trip label is now its registry
name, standard+expandedNotes (was standard-expanded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the position oracles in formatted and paragraph-structure (PT-4197)

logicalModel.oracle gains formatted and paragraph-structure (no
separators); canonicalLocations gains paragraph-structure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the footnote popover round trip over every editing host (PT-4197)

note-ops-popover-roundtrip mounts its host in Standard, formatted and
paragraph-structure, and its popover with footnotePopoverView(host), the
options paranext-core's FootnoteEditor builds (so the Standard popover
now also carries isNoteShellEditable: false, as the app's does). Glyph
shape and the Tier-2 fixed point are asserted only over an editable
host; ops/USJ idempotence and the Save write-back hold in all three.

New: Enter inside the popover's note content. It keeps the note whole
in every view, but over a hidden-marker host (formatted,
paragraph-structure) Enter is Lexical's own paragraph split: the caret
leaves the note for a new scaffolding paragraph, so the keystrokes that
follow are outside the note and the popover's Save (note op only) drops
them. Pinned as known failures.

knownFailureRunner now returns a registrar that appends the reason to
the test name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the corpus transform legs through the real Editor per view (PT-4197)

corpus-transform-fixed-point and the testUSFM transform leg mounted only
CharNodePlugin, MarkerEditPlugin and TextSpacingPlugin, so in formatted
and paragraph-structure the transforms those views actually run (the
note, paragraph and paragraph-marker-prefix plugins) never ran. Both now
mount the real Editor in each editing view through mountInView, dirty
every node once and read the document back with the adaptor Editor uses
on a local change. The composed provider-USX suite shares the same
runEveryTransform helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: annotation and undo rows in paragraph-structure and every editing view (PT-4197)

exactAnnotationLocations rows that stopped at standard/formatted (figure
caption hold, undo/redo, comment inside a caption, an NBSP inside a char
span, labels at a range's end) also run in paragraph-structure.
undoReachesFile's plain-text undo/redo control runs in all three
editing views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: navigation-then-edit save pins in every editing view (PT-4197)

navigationThenEdit and scrRefNavigationThenEdit run in Standard,
formatted and paragraph-structure: an edit right after a navigation
must still reach onUsjChange (a lost onUsjChange is a lost save).

Each row starts from no document selection. Without that, a row that
follows another inherits jsdom's stale selection and stops detecting
the leak it pins: with a cursor-change tag forced onto the navigation's
caret placement, only every other row failed until the reset was added.
scrRefNavigationThenEdit also asserts the reported USJ carries the note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: host change-notification rows in every editing view (PT-4197)

onUsjChangeSettled's view-independent rows run in Standard, formatted
and paragraph-structure: once per content commit (a new plain-text
row; the attribute row stays Standard's), nothing for a selection-only
commit or a commit that changes no bytes (after a load, a reload, and a
paragraph touch after a lossless or double-space load), a local
applyUpdate announced once, and every remote applyUpdate row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: Editor undo and character-marker ref rows in every editing view (PT-4197)

The verse-spanning-delete undo regressions (guarded and native) and the
removeCharacterMarker/replaceCharacterMarker/extendCharacterMarker ref
rows that mounted the library's default (formatted) view now run in
Standard, formatted and paragraph-structure.

Two extendCharacterMarker rows fail in Standard view and are pinned as
known failures: coalescing two \bd runs leaves two adjacent \bd spans,
and extending \bd with \nd as a conflicting marker removes \nd but
never applies \bd while reporting success.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: note EditorRef suites in every editing view (PT-4197)

noteIndexAndHighlight, applyUpdateDomSelection and noteLooseTextApply
mount through optionsInView(view) in Standard, formatted and
paragraph-structure. The one row whose premise is Standard's display of
an unclosed note's caller (plain caller text, not an immutable caller
element) stays editable-marker only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: paragraph split and deletion content rows in every editing view (PT-4197)

charStackParagraphSplit and paraWholeDeletion pin the marker-edit
engine on hand-built Standard trees. Each gains real-Editor rows over
Standard, formatted and paragraph-structure judged on the saved USJ:
Enter inside \nd (and inside \wj \+nd) keeps both halves styled, and a
deletion of a paragraph's text, or across a paragraph boundary, keeps
the content the selection did not cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: word the navigation row-isolation comment as the hazard it is (PT-4197)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: satisfy the typecheck in two per-view rows (PT-4197)

The popover suite's known-failure map is a plain string map, and the
deletion rows' Delete keypress checks the editor root for null itself
(the marker-edit requireDefined admits null).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: never save an empty span's placeholder or drop an authored `~` (PT-4391)

Follow-up review fixes to the lone-NBSP content rule outside Standard view.

- CharNodePlugin: merging adjacent same-attribute glyph-less spans no
  longer carries an empty span's placeholder into the merged content.
  Landing after a nested span it read as content (the regression), and
  after a text-ending or `~` span Lexical merged it into the text before
  (`x~`, `~~`). The empty side now gives up its children and a caret on
  its placeholder moves to the seam. An author-marked `~` is never
  treated as a placeholder.
- Remove-style ($removeCharNodeKeepingContent) unwraps an author-marked
  lone `~` instead of deleting the span with it.
- NoteNodePlugin stripped a leading NBSP from any note char text on any
  transform (`\ft ~x` saved `\ft x`; typing after `\ft ~` lost it). It now
  uses CharNodePlugin's placeholder rule, which drops only a byte typed
  over a span that held nothing but the unmarked placeholder.
- $isCharContentEmpty counted any NBSP-only text as empty, so a
  close-and-reopen after a `~` span's `~` dropped the span with its `~`
  (unformatted, formatted, paragraph-structure). It now strips only the
  separator and asks $isLoneNbspContent about a lone NBSP.

Tests: gesture-level guards through the real Editor in every view
(lone-nbsp-gestures), CharNodePlugin merge/typing and NoteNodePlugin
units, $isCharContentEmpty units, and live/serialized agreement rows
inside spans and notes (read-only glyphs, collapsed-caller notes,
author-marked nodes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: preserve the USX/USJ version end to end (PT-4197)

A document now leaves the converters and the editor at the version it
arrived at, instead of being re-stamped 3.1 and forced back to 3.0 by the
host.

- `usxStringToUsj` keeps the root `<usx version>` (USJ_VERSION only when the
  USX declares none or an empty one); `usjToUsxString` writes `usj.version`
  (USX_VERSION only when the USJ has none).
- `Usj.version` is widened from `typeof USJ_VERSION` to `string` — a public
  type change, recorded in the utilities CHANGELOG and api report.
- The `usxVersion` option on `usjToUsxString` (Unreleased only) is removed;
  the round-trip audit suites now round-trip the version with no help, and
  the testUSFM USJ -> USX -> USJ net compares whole documents, version
  included.
- The editor remembers the version of `defaultUsj` / each `setUsj` and
  stamps it on every USJ it builds (load, local commit, apply, stale
  refresh, settled read via `$settledUsj`). The reverse adaptor takes the
  version to stamp; internal scratch USJ fed to the forward adaptor is
  unchanged. Loading a non-USJ_VERSION document no longer warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Swallow Enter inside an expanded note where markers are not editable

Outside Standard view nothing turns Enter in a note into a \fp break, so
Lexical split the paragraph holding the note and left the caret outside
it; what was typed next was lost on Save. NoteEnterGuardPlugin claims
KEY_ENTER, INSERT_PARAGRAPH and INSERT_LINE_BREAK at critical priority
while the selection touches an expanded note, in every markerMode but
"editable".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(platform-editor): save no space typed right after a char opener, as Paratext 9

Standard view saved a space typed directly after a char span's opener as
content (`\nd  things\nd*` -> content " things"). Paratext 9 skips all
whitespace after any opening marker (UsfmToken.Tokenize), so its Standard
view saves `\nd things\nd*`, and so does ParatextData on Platform.Bible's own
save path (ConvertUsxToUsfm runs NormalizeUsfm) — the editor's getUsj() was
the only place the space survived, so the editor reported a document the
file never held.

The span's content now drops its leading plain spaces on save, the rule a
paragraph's start and the text after a verse number already follow: the
screen keeps the typed space and the caret after it; the file does not get
it. The logical position model drops the same spaces so positions agree with
getUsj(). A span holding nothing but spaces keeps them (a selected space
wrapped in a span), since dropping that would join the words around it.

Catalog row O-5 (PT-4197). Pins updated deliberately: typedSeparatorSpace,
separatorGapSettle, verseAdjacentTyping, charAttributeTypedSettle (Standard
only; unformatted keeps whitespace), the adaptor caller-lookalike case, the
logical-model unit and oracle tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(platform-editor): report the space before a verse after a char span while pending

When a settle creates a verse directly after a char span (e.g. a `\` typed in
front of `v 2 b` after `\w grace\w*`), TextSpacingPlugin's verse transform
puts a space between them once the verse lands. The read-only settle behind
a pending getUsj() creates no live nodes, so it reported the span and the
verse touching, then the same bytes came back spaced after the settle.

The read-only settle now mirrors the transform over the output of every
paragraph or chapter scope it re-tokenizes. A scope the live settle only
renames in place (signature unchanged) re-creates no verse, so it is left
alone on both sides.

Paratext 9 has this space too: UsfmToken.Tokenize inserts one between a
closing marker and a following \v on every read, and the file is the same
with or without it (the writer puts a line break before \v) — measured with
ParatextData 9.5.0.24. Catalog row O-7 (PT-4197).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(platform-editor): rebuild committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep pasted prose inside the char span or note it is pasted into

Lexical inserts a paste's nodes (internal flavor, or text/html, which is what
Ctrl+V delivers) with RangeSelection.insertNodes, which splits every inline
ancestor of the caret up to the paragraph: text pasted inside \nd Lord\nd* split
the span and landed between the halves, and text pasted inside a note landed in
the paragraph after it (cut + paste back inside a note moved the text out).

A SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler now inserts a payload of plain
prose as text when the paste starts in ordinary text inside an inline element,
the way typing it would. Structured payloads are still Lexical's.

Found by the PT-4197 clipboard corpus sweep (finding CB-2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the no-break space a cut leaves as a char span's only text

In the views without char glyphs a span's lone NBSP is also the empty-span caret
placeholder, which the save drops unless the text is tagged as the author's. Only
the USJ-to-editor adaptor tagged it, so cutting or deleting `trail` from
`\nd trail~\nd*` saved an empty span: the NBSP outside the selection was lost.

A CharNodePlugin transform now tags a lone-NBSP text in a glyph-less span as
authored when that text held more than the placeholder in the last committed
state.

Found by the PT-4197 clipboard corpus sweep (finding CB-4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sweep copy, cut and paste over the testUSFM corpus in every editing view

Standard view's USFM clipboard has a corpus sweep; formatted and
paragraph-structure had none. The new suite samples whole verses, ranges
starting or ending inside a char span, ranges holding a footnote, crossing a
paragraph boundary or a verse number, spanning a paragraph, and inside note
content, then copies and pastes (all flavors, html+plain as Ctrl+V delivers,
plain), cuts, cuts and pastes back, and pastes USFM-looking text, checking each
result and its undo/redo against a USJ-level model.

Pinned with knownFailureRunner (68 rows):
- paragraph-structure: a copy across a paragraph boundary carries the gutter
  glyph (\q1) in text/plain, so CommandMenuPlugin refuses its paste (CB-1);
- hidden views, html flavor: char attributes (closed) are not carried and a
  note's display spacers come back as text (CB-3);
- Standard reference rows over damaged USFM (2SA-5) and one derived-sid loss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rebuild the committed dist for the clipboard fixes

Carries the inline-prose paste and the remaining-NBSP transform into
packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: typing-fidelity oracle harness for the hidden-marker views (PT-4197)

A keystroke oracle for formatted and paragraph-structure, where no
pending marker edit exists to settle. The expectation for each keystroke
is computed from the loaded USJ alone: each leaf block read as units (a
character, or an atom shown whole: verse, collapsed note, milestone,
figure), a caret as a unit index, and typing, Backspace, Delete, Enter
and typing over a range edited into the USJ tree there. The saved USJ
must be one of the expected documents, Undo must restore the loaded USJ
exactly and Redo re-apply the keystroke's.

Keystrokes arrive as a browser delivers them: the caret placed with a
selection change, the DOM selection made the editor's, a real keydown
on the root, then beforeinput and the text insertion. Selection.modify
(which jsdom lacks and Lexical's deleteCharacter needs) is supplied at
character granularity. Failures are reported and listed by shape.

Exports the pending-equals-settled oracle's keystroke corpus for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: Delete before a collapsed note no longer throws (PT-4197)

In the hidden-marker views, Delete just before a collapsed note removes
its caller; NoteNodePlugin then removed the note and went on to move its
first child out of the removed node, which threw ('Expected node to have
a parent'), discarding the edit. Return once the note is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: joining a paragraph into an empty \b keeps its text (PT-4197)

ParaNodePlugin refuses text typed into an empty blank line by clearing
it. A join with the paragraph after it (Backspace at that paragraph's
start, Delete in the blank line, or a range across the break typed over)
also lands content in the empty \b, and the clear deleted that whole
paragraph's text in the formatted view. Clear only when the paragraph
after the blank line is still there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: Undo takes back Enter in front of a char span or a note (PT-4197)

Lexical's history folds an update whose only dirty leaf is a text that
kept its text into the previous undo step, even when tagged as a push.
A paragraph break in front of an inline element is such an update in the
formatted view, so Undo did not take the break back. ParaNodePlugin now
marks a created paragraph's first leaf dirty as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: an author's no-break space left alone in a span is saved (PT-4197)

In the views without a span separator (hidden and visible markers), a
lone NBSP in a span is the empty-span placeholder unless it carries
authoredNbspState, which the adaptor set only on a span text that was
exactly one NBSP at load. Deleting the rest of \nd ~x\nd* (or x~)
left an unmarked NBSP, and the span saved empty; an NBSP typed into an
empty span lost to the placeholder drop the same way.

The adaptor now marks every span text holding an NBSP (Lexical keeps
node state through text edits and copies it on splitText), and the
placeholder drop marks the NBSP it leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: typing over a range across a span edge or a note keeps the rest (PT-4197)

Lexical's RangeSelection.insertText moves the content after a range's
last text to after its first text, into the inline element the range
starts in, and resolves a range start at a text's end into the inline
element after it. In the hidden-marker views that pulled the text after
an enclosing char span inside the span (out of order), and, for a range
starting just before a collapsed note, put the typed text and the rest
of the range into the note, which was then removed with them.

InlineRangeEditPlugin (mounted for markerMode hidden) replaces a range
over more than one text of one paragraph itself: it cuts the edge texts,
removes the nodes between and the spans and notes that leaves empty,
and puts a point Lexical resolved into a collapsed note back beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the typing-fidelity oracle in formatted and paragraph-structure (PT-4197)

Two files per view (the keystroke corpus with the short testUSFM
chapters; the long testUSFM and WEB chapters), paragraph-structure under
Simple mode's guarded structure protection. The shapes still failing are
listed with their reasons: Redo lost or a second Undo needed around an
empty verse, one Enter-at-paragraph-start Undo in WEB Matthew 5, and one
ignored Backspace at an element caret in 1 Samuel 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: rebuild the committed platform output (PT-4197)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(platform-editor): rebuild committed dist for the typing and clipboard merges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test(platform-editor): hold each keystroke to keeping content, and pin separator removal

The pending/settled oracle compared the pending document with the settled
one, so content a transform dropped in the keystroke's own update was
invisible: the pending document already lacked it and settled to itself.
A mutation that removes a span's following text once its separator is
deleted passed every check. Each edit's screen is now read as the edit
left it (before transforms) and as it landed, and the landed screen may
only respell the spliced one: no content byte (whitespace and marker
tokens aside) may go. Range keystrokes are excluded: the oracle replaces
a range with a direct insertText that skips the commands the marker-edit
engine answers, so their transforms are not what a keystroke does. All
four oracle views stay green with nothing new listed.

Catalog row O-8 (PT-4197) reported Backspace after a `\w` opener's
separator losing the span and the text after it. It does not: the bytes
on screen (`\p In the \wgrace|grace\w* of God`) settle, pending and
settled alike, to `\p In the ` and a new unknown paragraph `\wgrace`
holding the rest — ParatextData's own reading (an unknown marker is a
paragraph outside a note, a char span inside one; checked against its
USX). The report came from a probe that printed only the first block.
New cases pin it for Backspace and Delete across attributes, `\nd`,
nesting and a note, comparing the whole document with ParatextData's USX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(platform-editor): save a char span holding only spaces as empty, as Paratext 9

Standard view already dropped the spaces after a char opener's separator,
but kept them when they were all the span held (a selected space wrapped
in `\nd`). ParatextData skips all whitespace after an opener there too:
`one\nd  \nd*two` saves as `one\nd \nd*two` (USX `<char style="nd" />`),
so the words join in the file. The editor's saved USJ now matches; the
screen still shows the space inside the span. The logical position model
drops the exception with it, so positions agree with getUsj().

A no-break space is data and stays: `\nd ~lead\nd*` and `\nd ~\nd*` keep
it on load and with a space typed in front of it (new tests; ParatextData
keeps both too).

Pin updated deliberately: markerMenuApply's whitespace-only wrap now saves
`["one", {nd}, "two"]` while the live span still holds the space.

Catalog row O-5 follow-up (PT-4197).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform-editor): rebuild committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(shared-react): keep alike char spans apart once a deletion leaves them side by side

In the hidden-marker views (formatted, paragraph-structure) CharNodePlugin
combined two adjacent same-marker, same-attribute spans whenever either was
dirtied, so deleting the text between `\w one|lemma="x"\w*` and
`\w two|lemma="x"\w*`, or joining the paragraphs they end and start, saved one
`\w onetwo\w*` and changed word segmentation (typing oracle finding T-7).
Standard view keeps them apart (its spans carry glyphs), and so does
Paratext 9.

Two spans that were already alike, separate spans in the last committed state
now stay separate. Spans an update creates (a marker applied to a run, a
split's continuation, remote content) still combine with an alike neighbor,
as does a span whose marker an update changes to match.

- Regression tests in every editing view plus paragraph-structure (guarded):
  deleting the space between, and joining the paragraphs.
- CharNodePlugin unit tests that expect a combine now add their spans by an
  update once the plugin runs (they previously relied on the plugin's
  registration re-dirtying the initial state, i.e. spans the document already
  held); new unit test for the keep-apart case.
- Typing oracle: drop the "adjacent alike spans are one" allowance; both views'
  corpus and chapters pass without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform-editor): rebuild committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test(clipboard): name the tickets that fix the clipboard corpus pins

The paragraph-structure gutter-glyph copy pins (CB-1, 44 rows) name
PT-4911, and the hidden-view html-flavor pins (CB-3, 13 rows) name
PT-4921, in a TODO beside each rule and at the head of its reason. The
Standard-view damaged-USFM and derived-sid rows have no ticket yet and
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(shared-react): arm a chapter for the guarded two-press delete

Under "guarded" structure protection (Simple mode) a chapter marker beside the
caret was outside the two-press guard: Delete at the end of the block before
`\c N` removed the chapter on the first press with no arming, and Backspace at
the start of the paragraph after it armed that paragraph, whose merge then
did nothing because a paragraph cannot merge into a chapter (typing oracle
finding T-10).

$structuralDeleteTarget now resolves the atomic chapter glyph
(ImmutableChapterNode) beside a block edge as a "chapter" target. The first
press node-selects it (as for a verse) and the armed chapter carries the
verse-selected class, so it shows the armed styling; the second press removes
it and returns the caret where the first press found it. The opposite key
cancels. No host hint kind is published for a chapter (the host's hint copy is
verse/selection-only), matching paragraph merges. Standard view (editable
chapter text), "protected" (still refused) and "off" (Power mode) are
unchanged.

Tests: guardedChapterDeletion.test.tsx in formatted and paragraph-structure
views - Backspace after / Delete before `\c 1` and `\c 2` arm then delete
(guarded), cancel on the opposite key, refused under protected, native under
off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(platform-editor): keep a nameless character marker's empty name on export

ParatextData reads a bare `\` inside a note (`\f + \ft \ b\f*`) as
`<char style="" status="unknown">`. The converters already keep the empty
style as `marker: ""`, but the editor's export turned an empty char marker
into no marker at all, so getUsj() handed back a char with no name and the
USX written from it was not the USX loaded. createCharMarker now keeps
`marker: ""`, the way nameless paragraphs already round-trip.

A char with no marker at all in its USJ (not a shape USX can express) now
exports as `marker: ""`, as an absent paragraph marker already exports as `p`.

Tests: namelessChar.test.tsx loads ParatextData's captured USX for an unclosed
and a `\*`-closed nameless span in a footnote, and asserts getUsj() and the
USX written from it are unchanged in Standard, formatted and
paragraph-structure views; converter tests for a char's empty style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* docs: correct what ParatextData does with a marker-bearing \cp

Comments claimed ParatextData keeps every `\cp` that holds a marker as its own
marker. Measured, it folds the text before the first marker into the
chapter's pubnumber and leaves the rest as loose content after the chapter;
only a `\cp` whose content starts with a marker stays a `\cp`. The editor
deliberately settles such a `\cp` to the USFM-spec shape: the saved bytes are
the ones Paratext 9 writes, and a reload shows Paratext 9's shape. No
behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform-editor): rebuild committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* feat(shared-react): publish a chapter hint kind for the guarded delete

An armed chapter in Simple mode's two-press delete now sets
data-verse-delete-kind="chapter" (with data-verse-delete-intent) on the
editor root, as an armed verse or selection does, so the host can show a
chapter-specific "press again" hint. A paragraph merge still publishes no
hint kind. CHANGELOG records the chapter arming and the new value.

Tests: guardedChapterDeletion (formatted + paragraph-structure) and the
StructureKeyboardPlugin DOM-signal suite expect kind=chapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test(platform): Standard view may drop an empty derived sid on a verse paste

Pasting `\v 9 x` as plain text in Standard view rebuilds part of the
paragraph it lands in, and the 2SA-3 fixture's empty derived `sid`s on the
re-derived verses do not survive. Dropping derived metadata is accepted
(data-fidelity catalog F-8), so this is now an expectation rather than a
known failure: in the paragraph the verse lands in, an empty `sid` may be
dropped, and everything else (non-empty `sid`s included) must still match
exactly. The row still performs the paste, undo and redo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test: name the tickets for the remaining untracked expected failures

- PT-4924: typing-oracle T-11 / T-12 / T-16 entries in both views'
  expected-failures JSON (in each reason).
- PT-4925: Standard view's extendCharacterMarker coalesce/conflict rows
  (EXTEND_KNOWN_FAILURES).
- PT-4388: the Standard-view 2SA-5 damaged-USFM clipboard rows and the
  fragment tokenizer's "testUSFM 2SA 5" known divergence (bare `\` as a
  nameless marker, waiting on ParatextData 9.6).

Every pin and what it asserts is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix: a join into an empty \b keeps the joined paragraph, as Paratext 9 (PT-4197)

Backspace at the start of a paragraph after an empty \b, Delete in the
blank line, or a range across the break typed over joined the paragraph
into the \b and kept the blank-line marker on text. Paratext 9's
formatted view joins paragraphs the way Gecko joins two blocks: the
second block survives (JoinNodeTransaction keeps the right node), so the
blank line goes and the paragraph keeps its own marker. ParaNodePlugin
now gives a blank \b that takes in a joined paragraph that paragraph's
marker and attributes, rewriting a gutter glyph to match, in formatted,
paragraph-structure and Simple's guarded two-press join. Standard view
edits marker text and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test: a typed U+00A0 saves as a space in Standard view, as Paratext 9 (PT-4197)

Paratext 9's Standard view (AllowInvisibleChars off) saves every U+00A0
in the editor as a space (UsfmHtmlUtilities.LegacyPostprocessUsfm) and
the author's no-break space is typed as `~`. Ours does the same; pin it
between letters, after a space and at a span's start, and pin `~` typed
saving an NBSP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build: rebuild committed dist for the \b join fix (PT-4197)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(shared-react): paragraph-structure Enter drops the break's space

Enter at a word break (`In| the`) left ` the` in the new paragraph in the
paragraph-structure (Simple) view, while Standard and formatted save
`the`. ParaNodePlugin trims a new paragraph's leading space from its first
child, which in that view is the gutter marker glyph rather than the text,
so nothing was trimmed. It now looks past a leading gutter glyph.

Tests: typingFidelity.regressions "Enter at a word break" in all three
editing views, unguarded and guarded (red in paragraph-structure: saved
" the beginning"). The typing oracle no longer accepts a new paragraph
that starts with text keeping its leading space (it fails 23 corpus rows
without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* feat(platform): ViewOptions.allowInvisibleCharacters keeps ~ and U+00A0 apart in Standard view

A Paratext project with AllowInvisibleChars on stores a no-break space as
U+00A0 itself and `~` as an ordinary character (measured with ParatextData:
load, save and round trip all carry both verbatim and distinct). Standard
view always applied the `~` = NBSP rules, so a literal `~` in such a
project was read as a no-break space and saved as U+00A0, and a U+00A0
typed there saved as a space.

With the new option on, a stored U+00A0 displays as NBSP_SYMBOL (U+237D)
instead of `~`, `~` stays an ordinary character, and every layer that
maps display or fragment text to data takes the spelling from
getNbspSpelling(viewOptions): the load/save adaptors, the tier-2 and
virtual settle tokenizer (new UsfmFragmentOptions.nbspSpelling), the
Unformatted content fragments, paste, copy (U+00A0 written as itself),
the Markers view's copy, and a typed U+00A0 (command and native
beforeinput routes). getViewMode ignores the field. Off is unchanged.

Tests: invisibleCharacters.test.tsx (load/save, edit, span and note,
typed ~, typed U+00A0 by both routes, paste, copy, settle and unsettled
read, Unformatted settle, Markers copy, formatted/paragraph-structure
pins), tokenizer nbspSpelling, getViewMode/getNbspSpelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* feat(platform): toUsfmNbspSpelling for hosts that read the editor's display text

A host that copies or searches text read from the editor's DOM (the
footnote popover's Copy button, a Find seeded from the selection) sees
Standard view's display spelling of a no-break space. With
allowInvisibleCharacters on that is U+237D, which is not the project's
text; this turns it back into U+00A0, and leaves the text unchanged when
the option is off (`~` is the project's spelling then).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test(platform-editor): keep a note's empty caller empty through edits in every view

ParatextData reads `\f \ft x\f*` (one or two spaces after the marker) as
caller="" and writes it back as `\f \ft x\f*` (measured with the corpus
capture harness), and the tokenizer has followed that since PR #11. The
existing rows covered only the expanded editable views; this pins a
load, an edit beside the note and one inside it, and a departure in
every editor view, saving caller "".

Fails in all 8 views if the exporter writes "+" for an empty caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* feat(platform): U+001A is the no-break-space stand-in, drawn as a blank, and never leaves the editor

With ViewOptions.allowInvisibleCharacters on, Standard view held a stored
U+00A0 as U+237D (shown as a visible box glyph). U+237D can occur in
Paratext 9 text, so a literal one was saved as a no-break space. It now
holds U+001A (SUBSTITUTE), which ParatextData's ScrText.GetText turns
into a space on every read, so no project text can hold one.

- Rendering: a one-glyph font (empty, 0.25em wide, unicode-range U+1A,
  NBSP_STAND_IN_FONT_FAMILY) draws it as a blank the width of a space.
  The editor adds the face to its document through the CSS Font Loading
  API (from bytes, so no CSP font-src is involved). Chromium draws a
  control character as a box unless the FIRST family has a glyph for
  it, so generateUsjCss and usj-nodes.css lead every Scripture
  font-family with it.
- It never leaves the editor: delta ops given out (onUsjChange,
  getNoteOps, getOpsAfterNote, takeOpsAfterNote) carry U+00A0 for it and
  a space for an NBSP the view shows in a space run; applyUpdate and
  replaceEmbedUpdate take a U+00A0 that is not a marker separator as a
  stored no-break space; the internal Lexical clipboard flavor is left
  out when the selection holds a stand-in; a drag out carries the copy's
  USFM text and html; annotation callbacks and note-caller previews give
  U+00A0.
- Incoming text: every C0 control character but tab, LF and CR becomes a
  space in Standard view before anything reads it (load incl. attribute
  values, paste, drop, typing by both routes, applied ops).
- A drop into Standard view now goes in as a paste of the same data.
- The fragment tokenizer keeps the spelling as a character
  (regularizeSpaces turned control characters into spaces first).

Leaking before (measured against the U+237D build): ops out, note ops,
the footnote-editor round trip, ops in (U+00A0 saved as a space), the
Lexical clipboard flavor, the cut, the async paste, drag and drop, and
annotation text. Forced-on run (every registry view with the option):
only the 5 intended off-mode pins fail.

Known gap (both settings, not changed here): an attribute value's
U+00A0 is shown raw in its attribute run, and a settle of its paragraph
reads it as a space.

Tests: nbspStandIn.test.tsx (35), whitespaceDisplay.utils.test.ts
(control characters, ops mapping), usj-nodes.css and generateUsjCss
font stack, invisibleCharacters.test.tsx on the new stand-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(platform): a note caller's click hands out the note's ops with U+00A0

The getNoteOps a noteCallerOnClick handler receives read the note's ops
straight from the tree, so with allowInvisibleCharacters on they carried
the internal stand-in; Platform.Bible's footnote popover applied them,
and the incoming rule turned each stand-in into a space (seen in the
running app). They now go out through opsForHost like every other op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* Address review-paratext findings on the data-fidelity branch

- Fix InlineRangeEditPlugin: typing over a range whose two edge texts are
  both wholly selected no longer falls through to Lexical, which pulled the
  text after a char span into the span. The plugin now steps aside only for
  a whole-edge deletion. Regression rows added to
  typingFidelity.regressions.test.tsx.
- Add standardViewDrop.test.tsx: drop-as-paste rows under both
  allowInvisibleCharacters settings.
- Add the missing CHANGELOG entries (platform Fixed section; utilities note
  that the Scribe editor does not yet carry the USJ version through).
- Replace in-code Jira/branch references with forward-facing wording per
  CLAUDE.md, keeping open TODO(PT-x) markers.
- Document that allowInvisibleCharacters must be right before the first
  load; correct the vertical-tab/form-feed wording; clipboard-semantics
  doc updates.
- Cleanups: $collapsedNoteOf uses $findMatchingParent, ParaNodePlugin key
  lookup, CharNodePlugin TSDoc, un-export $isSelectionInExpandedNote,
  NoteEnterGuardPlugin ordering comment, corpus fixed-point test uses
  knownFailureRunner, reshaped half-flat test trees, comment re-wraps,
  demo armed-chapter CSS.
- Rebuild committed packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_01KiNAWjh4KAwjzn1g34EePL

* fix(platform): an attribute value's no-break space keeps its spelling in Standard view

Standard view showed a U+00A0 inside an attribute value as a raw U+00A0
in its attribute run, which the engine reads as display spacing, so a
settle of the paragraph (Tier-2 rebuild or virtual settle) saved it as a
plain space, in both ViewOptions.allowInvisibleCharacters settings. The
value now takes the body text's spelling (`~`, or the U+001A stand-in
with the setting on) everywhere attribute state becomes display text:

- the adaptor's runs for char spans, milestones, \va/\vp, \cat, \ca/\cp
  and an UnknownNode's display parts (figure src, sidebar \cat, periph);
- the display-run syncs, which redraw from node state without view
  options: MarkerEditPlugin registers the editor's spelling
  (registerNbspDisplaySpelling / $getNbspDisplaySpelling in shared);
- the `|...` bytes an unwrapped or emptied span leaves as content, a
  collapsed note's copied \cat, and the annotation byte maps.

The tokenizer reads the spelling back as U+00A0 inside milestone
(including an absorbed list), figure and \periph attribute values, as it
already did for body text, char-span attributes and the attribute
markers. Unformatted view, which shows a value's U+00A0 as itself, now
respells it past the run's leading separator in its settle fragment
(attributeFragmentText), as it does content, instead of flattening it to
a space.

Tests: attributeValueNbsp.test.tsx (each producer shape in Standard view
with the option off and on: display, paragraph settle, value edit read
before and after the settle, copy and paste with every flavor and with
plain text alone; figure; span unwrap and paragraph-break emptying; a
collapsed note's copied \cat; Unformatted; formatted and
paragraph-structure pins) and tokenizer cases in
usfmFragmentToUsj.test.ts. The nbspStandIn chapter row now settles the
chapter line instead of pinning the raw display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(shared-react): rejoin a char span that Enter split when Backspace joins the paragraphs back

Keeping alike char spans apart once both are in the document also kept apart
the two halves of one span that Enter cut in two, so Enter then Backspace
inside `\nd thing\nd*` saved `\nd th\nd*\nd ing\nd*` in the views that hide
markers.

- CharNode.insertNewAfter records, per editor, which span each split
  continuation came from; $wereSplitFromOneSpan lets those pieces merge again.
  Spans the document held apart still stay apart.
- Regression test in every hidden-marker view: Enter inside a span, then
  Backspace, saves one span.
- InlineRangeEditPlugin: comment the composition gap (an input method started
  over a cross-span range takes Lexical's own path).
- CHANGELOG entry extended.
- Rebuild committed packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_018WCZ5KDNh1Vqzt3JP6jCnp

* fix(shared-react): keep Undo/Redo working around empty paragraphs and caret hosts

- Undo after a guarded two-step removal of an empty paragraph (an empty
  `\b`) now brings it back. Lexical's history folds an update whose only
  dirty leaf is a text that kept its text into the step before, even one
  tagged history-push, so `$mergeParaIntoPrevious` marks the removed
  paragraph's leaves dirty to make the removal its own undo step.
- Redo works after an Undo leaves the caret beside an empty verse or a
  trailing note. Caret-host commits (create, move, removal on blur) are
  tagged history-merge through `$tagCaretHostCommit`, so they no longer
  become an undo step that clears the redo stack.
- Drop the testUSFM-2SA-2/2SA-3 rows from both typing-oracle
  expected-failure lists: the Undo/Redo shapes they pinned now pass.
- Pin an author's literal `~` in a milestone attribute value turning into
  U+00A0 on a Standard-view settle with `it.fails` (needs a product call).
- Use a code span for `USJ_VERSION` in `Usj.version`'s TSDoc so
  consumers' typedoc can resolve the comment.
- Rebuild the committed dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_018WCZ5GnNin2H8EzQyWWq6p

* fix(platform): keep a pasted no-break space at the start of a line

Standard view's paste read a U+00A0 at the start of the pasted text, or
of any line of it, as display spacing and inserted a plain space. So a
pasted lone no-break space became a space, one beside a space collapsed
away, and with allowInvisibleCharacters on the editor's own copy of a
stored no-break space pasted back as a space or as nothing (the copy
spells a stored no-break space as U+00A0 there).

No copy reaching the paste puts display spacing at a line start: the
editor's own copy writes a plain space for every display NBSP in both
readable flavors, a browser's default copy writes text/plain with every
NBSP as a space, and a Paratext 9 clipboard is decoded with its NBSPs
resolved. normalizePastedNbsp now keeps every pasted U+00A0 as a stored
no-break space except one right beside a marker token, which is still
read as a separator.

pastedNbsp.test.tsx pastes each case through the editor in both
settings: from a foreign text/plain and html-only clipboard, and the
editor's own copy pasted back. Two rows stay expected failures with the
setting on: a stored no-break space right after a closer or right
before an opener copies as U+00A0 and pastes back as a space under the
marker-adjacent rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(platform): stop exporting NBSP_STAND_IN_FONT_FAMILY

Hosts name the no-break-space stand-in's font family in their own
stylesheets, which cannot import a constant, so the export gave them
nothing to use. The constant stays internal to generateUsjCss and the
font registration, and the docs now state the family name,
PlatformEditorNbspStandIn, which must lead any host Scripture
font-family and must not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep an author's lone no-break space in delta ops

- The delta-op producer dropped every text that is exactly one U+00A0
  inside a note's contents, and every char span whose only text is one
  U+00A0 (as an empty span's placeholder). In views that hold a stored
  no-break space as U+00A0 itself (formatted, paragraph-structure), that
  is also a `~` between two of a note's char spans, or a `~` that is a
  span's whole text.
- Both checks now ask `$isLoneNbspContent`, the rule the editor-to-USJ
  conversion already keeps content by, so a note's ops (`getNoteOps`,
  which Simple mode's footnotes pane edits) carry what `getUsj()` saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(platform): Standard view's delta ops read as USJ text in both no-break-space settings

- `opsForHost`/`opsFromHost` told a marker's separator from a stored
  no-break space only within one op, and took any op that is exactly one
  U+00A0 as a spacer. The editor emits each text node as its own op, so a
  glyph's separator (`\nd`, then ` A`) went out as a space, and with
  `allowInvisibleCharacters` on a stored no-break space that is a whole
  op's text (a `\ft` that is only `~`, a `~` between two spans or after a
  note, text carried out past a note) came back in as display spacing and
  was saved as a space or dropped. An NBSP is now structural only right
  after an opening glyph, in the same op or ending the op before.
- With `allowInvisibleCharacters` off, Standard view's ops went out as its
  display text: `~` for a stored no-break space and U+00A0 for each space
  of a run. Simple mode's footnotes pane edits a paragraph-structure
  editor's notes in Standard view, so a pane edit saved a space run as
  no-break spaces, and a stored no-break space showed in the pane as a
  blank rather than `~`. Both settings now map the same way: the stored
  no-break space's spelling to U+00A0 and a run's NBSPs to spaces going
  out, U+00A0 to the spelling coming in.
- A paragraph-structure editor still reads a char span whose whole text
  is one U+00A0 from applied ops as an empty span's placeholder, so the
  pane drops that `~`; pinned with `it.fails`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(platform): mark toUsfmNbspSpelling experimental

It stays exported, but is expected to be removed once Standard view
holds a stored no-break space as itself (PT-4931): display text will
then already carry the project's spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep an applied no-break space that is a char span's whole text

- Applying delta ops built every char span's text without the mark a
  load puts on a span's no-break space (`authoredNbspState`). In the
  marker modes without a separator after a span's opener (formatted,
  paragraph-structure), a span whose whole text is one U+00A0 then read
  as an empty span's placeholder and saved as nothing: Simple mode's
  footnotes pane dropped a `\ft ~` (or any `~`-only span) its row editor
  handed back, and the same happened to a `~`-only span inserted by
  `applyUpdate`.
- The op producer leaves an empty span's placeholder out of its ops, so
  an applied U+00A0 is always content. `$markAppliedNbsp` marks the text
  of every char-span insert (`$handleCharText`, a note's contents, an
  unknown embed's contents) that holds one, as the USJ adaptor marks a
  loaded span's text; editable marker mode is left unmarked, as on a
  load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(platform): expect the footnotes pane to keep a `~`-only char span

The paragraph-structure main editor now marks an applied lone no-break
space as content, so the row that pinned the drop with `it.fails` runs
as an ordinary test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep a char span or milestone in text carried out past a note

Text a host's note editor carries out past its note (`takeOpsAfterNote`,
applied after the note with `replaceEmbedUpdate`) lost its markup:

- A char span from a Standard-view note editor became an unclosed span
  plus a stray `\nd*` in Standard view, and its glyphs became span text
  in the paragraph-structure and formatted views. The carried ops held
  the span's marker glyphs as text, the shape of the note editor's own
  delta document. `getOpsAfterNote`/`takeOpsAfterNote` now produce
  content-only ops (`$getParticularNodeOps`'s new `contentOnly` option),
  the shape a note's contents already have, and `takeOpsAfterNote`
  deletes what it carries by the nodes' apply-coordinates length
  (`$getApplyLength`), since the ops no longer count the glyphs.
- Applying a char span in editable marker mode added its glyphs but
  advanced past its text alone, so the ops after it landed inside the
  span's opener. `$handleCharText` now gives a fresh span the separator
  after its opener, as a load does, and counts everything it added.
- Plain text inserted at the end of a nested span went into the outer
  span; it now goes after the outermost span.
- A milestone vanished in every view: the op producer writes a milestone
  under `milestone`, and the applier read only `ms`. It now reads both.

Standard view still splits a carried `\nd L \+wj M\+wj*\nd*` into two
`\nd` spans, pinned with `it.fails` in noteCarriedText.test.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(platform): pin copying a whole note through the editor's own copy

A host's copy-this-note button selects the whole note, calls
EditorRef.copy() and puts the selection back with setSelection(). Pin
that the sequence writes the USFM a Ctrl+C of the note writes (no
display NBSP, a stored NBSP as ~ or U+00A0 by setting, no closer an
unclosed note lacks, no text after the note), applies the copy limit
(a note that does not fit copies nothing), and leaves the selection,
the document and a pending marker edit as they were when it runs in
one synchronous handler. The mount helper takes a copy limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(platform): read a pasted no-break space as a space only after an opening marker

A paste turned a U+00A0 right after any marker token, or right before
one, into a space. With allowInvisibleCharacters on, Standard view's own
copy writes a stored no-break space as U+00A0, so copying one that sat
after a closer or in front of a marker and pasting it back saved a
space.

Only a U+00A0 directly after an opening marker (\nd, \+nd) is now read
as a space: the marker's separator, as ParatextData reads it. After a
closer or a milestone's \* self-closer, and in front of any marker, a
U+00A0 is kept as a stored no-break space. A foreign copy of the display
that carries a run's or a note layout's spacing there (the⍽\nd,
\f +⍽\fr) now keeps it as a no-break space rather than a space: nothing
is lost. The two own-copy round-trip rows that pinned the old reading as
expected failures now pass; the paste suites are re-pinned to the new
reading, and a pasted \nd~Lord\nd* is pinned to keep every byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): emit milestone ops under the ms key the op types declare

`$getMilestoneOp` wrote a milestone as `{ insert: { milestone: … } }`, but
`OTEmbedTypes`/`validOTEmbedTypes` declare `ms`, and so do
`isInsertEmbedOpOfType` and the applier. A host reading ops through the
public type guard never saw a milestone, and `getInsertedNodeKey` (which
only recognises `validOTEmbedTypes`) could not resolve one. The producer
now writes `ms`; the applier keeps reading `milestone` too, so ops from
earlier builds still apply their milestones.

Tests that pinned `milestone` (adaptor ops, the shared converter
fixtures) now expect `ms`, the applier's `milestone` test is named as the
legacy-key case, and a produce-then-apply round trip pins the producer
key and that both milestones survive with sid/eid/who. Before the
producer change the round trip's apply half already passed (4e43000f
made the applier read both keys); the key pin failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): build adjacent char ops of different attributes as separate spans

Applying ops joined a char op into the span before it, or into the span
at the insertion point, when the two matched on marker and cid alone. So
the footnote editor's round trip (getNoteOps, applyUpdate,
replaceEmbedUpdate) turned `\w a|lemma="x"\w*\w b|lemma="y"\w*` into
`\w ab|lemma="x"\w*` in every view, and an op inserted right before a
`\w` span of another lemma went into that span's text, losing its own
lemma (and, for a nested char array, its outer span).

`$hasAllSameCharAttributes` (shared) compares the marker, the cid and
every other attribute. `$createNestedChars` and `$handleCharText` use it
to decide whether an op continues an existing span, and
`$charNodeTransform` uses it in place of its own attribute comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep adjacent spans that arrive in one paste apart

In the views that hide marker glyphs, `$charNodeTransform` joined two
alike spans that a paste inserted side by side, so pasting the editor's
own copy of `\w a\w*\w b\w*` or `\nd A\nd*\nd B\nd*` saved one span.
Standard view, and Paratext, keep them as the two spans the copied
content held.

`CharNodePlugin` records the char spans each clipboard insertion brings
in (`SELECTION_INSERT_CLIPBOARD_NODES_COMMAND`, and the protected-document
paste and drop, which insert directly), and `$charNodeTransform` keeps
two of them apart for the rest of that update. A pasted span still joins
an alike span the document held, so pasting a piece of a span inside a
span of the same marker extends it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(platform): rebuild the committed dist for the merged paste and ops fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): count an author's `~`-only char span in delta-doc positions

- The ops stream emits a char span whose whole text is an author's
  no-break space (`$isLoneNbspContent`), but delta-doc counting still
  took every lone U+00A0 that is a span's only child as the empty span's
  placeholder and counted it 0. Every delta-doc position after such a
  span (`\nd ~\nd*` in formatted, paragraph-structure and the other views
  without a separator after the opener) was one short of the ops stream,
  so a fast-path keystroke after it went out with a retain one too small.
- The placeholder check now asks `$isLoneNbspContent`, as the producer
  does. Apply coordinates are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep a note's `~` that follows the opener's separator in delta ops

- In editable marker mode (unformatted) the text after a note span's
  opener is the separator followed by the author's text. For `\ft ~\ft*`
  or `\fk ~\+wj x\+wj*\fk*` that is two no-break spaces; the producer
  stripped the separator and then asked `$isLoneNbspContent` about the
  node, whose whole text is not one U+00A0, so the author's `~` was taken
  for layout and dropped from the note's ops. `getNoteOps` and
  `getEditorDelta` then disagreed with `getUsj()`, which keeps it.
- A lone NBSP left after stripping a separator is now content; the
  position rule is asked only about text that kept its node's every byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): give an applied `~` the separator after a span's opener in editable mode

- `$createNestedChars` told an empty span's placeholder from first
  content by its bytes, so an op whose text is one U+00A0 (an author's
  `~` as a span's whole text, or leading a nested span) got no separator
  after the opener in editable marker mode. Saving then stripped the `~`
  as the separator: in the unformatted view, the footnote popover's save
  and the Simple-mode pane round trip both lost `\ft ~\ft*` and
  `\fk ~\+wj x\+wj*\fk*`.
- The placeholder is now known by where it came from (an empty op's
  text), carried through the merge recursion; every op's own text takes
  the separator. An empty op still gets only the placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(platform): pin that saved USJ and delta ops agree on every lone `~`

- `lone-nbsp-agreement.test.tsx` runs each lone-`~` shape (a span's whole
  text, between two spans, leading an element-first span; in body text
  and in a note) through every view in both settings of
  `allowInvisibleCharacters`: the saved USJ keeps it, `getEditorDelta`
  carries the same lone NBSPs, delta-doc positions after it match the
  ops stream, and a note's `~` survives a save of its own ops, the
  footnote popover's round trip and the footnotes pane's round trip.
  Text typed into an element-first spacer or an empty span's
  placeholder saves as content.
- The popover round-trip suite gains an authored `\fq ~\fq*` and
  `\fk ~\+wj x\+wj*\fk*` through the popover's load and save, in the
  editing views and unformatted.

Tests adapted from Jolie Rabideau's #36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep a newly marked span apart from an alike span beside it

In the views that hide marker glyphs, applying a character marker to the
text right after a span of the same marker joined the new span into the
old one: the marker menu or `insertMarker('nd')` on "God" after
`\nd Lord\nd*` saved `\nd LordGod\nd*`. Paratext 9 and Standard view make
a second span.

`getUsjMarkerAction` records every span a marker apply creates
(`$recordNewlyMarkedSpan`), and `$charNodeTransform` keeps a recorded span
apart from an alike span the document already held. The pieces one apply
creates still combine with each other; Enter-then-Backspace rejoining,
the op applier's nested fragments, a paste extending a span, and
`extendCharacterMarker` record nothing and keep combining.

A selection that reaches into part of an alike span and past it now
extends that span instead of nesting a copy inside it. This deliberately
departs from Paratext 9, which mangles the line in that case (TJ,
2026-10-27). Standard view kee…
lyonsil pushed a commit that referenced this pull request Nov 4, 2026
…rpus (#29)

* feat(utilities): let usjToUsxString stamp a caller-specified USX version

usjToUsxString(usj, { usxVersion }) stamps the requested version on the
root element instead of always writing USX_VERSION. USJ cannot carry the
source USX version (the editor regenerates usj.version everywhere), so a
host that needs its USX back at the version ParatextData served it must
say so explicitly. Default output is byte-identical to before.

Part of the PT-4197 data-fidelity audit; lets paranext-core drop its
post-hoc version="3.1" string replacement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: composed PDP round trip, USX-first net, corpus extension (PT-4197)

Pins the full load/save trip over the real per-chapter USX the paranext-core
PDP produces (vendored under testUsfmCorpus/pdp-usx/):

- corpus-pdp-usx-composed-round-trip: USX -> USJ -> mounted editor -> USJ ->
  USX per capture, in Standard, formatted and paragraph-structure views, with
  a pinned expected-divergence list (an unlisted divergence fails; a listed
  one that stops happening fails).
- testusfm-usx-usj-usx-round-trip: the USX-first converter net, so an
  asymmetric usx<->usj pair cannot hide from both directions.
- corpus-testusfm-round-trip and corpus-transform-fixed-point now run every
  fixture in all three editing views (they were Standard-only).
- New fixtures testUSFM-2SA-4 (NBSP shapes, verse bridges/segments, \fe, \x,
  \cl/\cd, callers, \ref), testUSFM-2SA-5 (damaged-but-at-rest: nameless
  markers, double \ca, chapter-line text) and testUSFM-1SA-1 (book intro),
  with .usj derived by the documented recipe (provider USX + usxStringToUsj;
  2SA-5 hand-corrected to the intended marker:"" shape).

Known losses are routed through each suite's expected-failure mechanism,
attributed to scripture-editors #11, #19 or PT-4391, so a merge that fixes
one flips the pin visibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: drop the pins SE #11 fixed (PT-4197)

SE #11 fixed the leading-NBSP strip in a char span, the double-\ca fold in
the tokenizer, and the nameless-marker converter bug, so their pins flipped
as designed. The 2SA-5 .usj no longer needs a hand edit: the converter now
writes exactly what it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep an authored lone-NBSP content string outside Standard view (PT-4391)

Outside Standard view a data NBSP is held as a literal NBSP, so a content
string that is exactly one NBSP (`\nd ~\nd*`, `\p ~`, a `~` between two
spans or after a note) was byte-identical to the engine's lone-NBSP display
nodes and the editor->USJ conversion dropped it, already at load.

The conversion now drops a lone NBSP only in the slots the engine's display
nodes occupy: right after an editable glyph, right after a read-only glyph
inside a span, first in a span or note, after a collapsed note's caller or
span, or inside an attribute run (tagged/token nodes and the mark cases are
unchanged). The one slot an author's `~` shares with a display node - a
span's first content in the marker modes without a separator, where the
empty-span placeholder sits - is disambiguated by a new node state,
`authoredNbspState`, that the forward adaptor's createChar sets.

The same rule is the live `$isLoneNbspContent` (node.utils.ts), used by the
logical content model and the Tier-2 fragment flattening so annotation
positions and settles agree with the export. CharNodePlugin no longer treats
an authored `~` as an empty span's placeholder when text is typed beside it.

Flips the pinned drops: constructed-usj-round-trip's paragraph-leading lone
NBSP, the 2SA-4 entries in the composed PDP round trip and the testUSFM
round trip (adaptor and transform legs), the formatted-view leading-NBSP pin,
and the logical-model spacer pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: one shared registry of editor views and one mountInView (PT-4197)

Every per-view list the editor suites iterate is now a projection of
EDITOR_VIEWS in editorViews.test-helpers.tsx: the location oracle's
ORACLE_VIEWS/oracleView, the corpus suites' ROUND_TRIP_VIEW_CONFIGS and
EDITING_VIEW_CONFIGS (moved out of corpus-data.ts, which is compiled
with the library and so cannot import a test helper), and the new
EDITING_VIEWS (standard, formatted, paragraph-structure).

The two private Editor mounts (settledGetUsj's mountEditor and the
location oracle's mountInView) merge into one mountInView(usj, view,
opts); mountStandardViewEditor/mountExpandedNoteEditor are thin
wrappers. Also adds footnotePopoverView(host) (mirrors paranext-core's
FootnoteEditor options), forEachView, knownFailureRunner, and
noteEditorRef's optionsInView(view).

The Standard-with-expanded-notes round-trip label is now its registry
name, standard+expandedNotes (was standard-expanded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the position oracles in formatted and paragraph-structure (PT-4197)

logicalModel.oracle gains formatted and paragraph-structure (no
separators); canonicalLocations gains paragraph-structure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the footnote popover round trip over every editing host (PT-4197)

note-ops-popover-roundtrip mounts its host in Standard, formatted and
paragraph-structure, and its popover with footnotePopoverView(host), the
options paranext-core's FootnoteEditor builds (so the Standard popover
now also carries isNoteShellEditable: false, as the app's does). Glyph
shape and the Tier-2 fixed point are asserted only over an editable
host; ops/USJ idempotence and the Save write-back hold in all three.

New: Enter inside the popover's note content. It keeps the note whole
in every view, but over a hidden-marker host (formatted,
paragraph-structure) Enter is Lexical's own paragraph split: the caret
leaves the note for a new scaffolding paragraph, so the keystrokes that
follow are outside the note and the popover's Save (note op only) drops
them. Pinned as known failures.

knownFailureRunner now returns a registrar that appends the reason to
the test name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the corpus transform legs through the real Editor per view (PT-4197)

corpus-transform-fixed-point and the testUSFM transform leg mounted only
CharNodePlugin, MarkerEditPlugin and TextSpacingPlugin, so in formatted
and paragraph-structure the transforms those views actually run (the
note, paragraph and paragraph-marker-prefix plugins) never ran. Both now
mount the real Editor in each editing view through mountInView, dirty
every node once and read the document back with the adaptor Editor uses
on a local change. The composed provider-USX suite shares the same
runEveryTransform helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: annotation and undo rows in paragraph-structure and every editing view (PT-4197)

exactAnnotationLocations rows that stopped at standard/formatted (figure
caption hold, undo/redo, comment inside a caption, an NBSP inside a char
span, labels at a range's end) also run in paragraph-structure.
undoReachesFile's plain-text undo/redo control runs in all three
editing views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: navigation-then-edit save pins in every editing view (PT-4197)

navigationThenEdit and scrRefNavigationThenEdit run in Standard,
formatted and paragraph-structure: an edit right after a navigation
must still reach onUsjChange (a lost onUsjChange is a lost save).

Each row starts from no document selection. Without that, a row that
follows another inherits jsdom's stale selection and stops detecting
the leak it pins: with a cursor-change tag forced onto the navigation's
caret placement, only every other row failed until the reset was added.
scrRefNavigationThenEdit also asserts the reported USJ carries the note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: host change-notification rows in every editing view (PT-4197)

onUsjChangeSettled's view-independent rows run in Standard, formatted
and paragraph-structure: once per content commit (a new plain-text
row; the attribute row stays Standard's), nothing for a selection-only
commit or a commit that changes no bytes (after a load, a reload, and a
paragraph touch after a lossless or double-space load), a local
applyUpdate announced once, and every remote applyUpdate row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: Editor undo and character-marker ref rows in every editing view (PT-4197)

The verse-spanning-delete undo regressions (guarded and native) and the
removeCharacterMarker/replaceCharacterMarker/extendCharacterMarker ref
rows that mounted the library's default (formatted) view now run in
Standard, formatted and paragraph-structure.

Two extendCharacterMarker rows fail in Standard view and are pinned as
known failures: coalescing two \bd runs leaves two adjacent \bd spans,
and extending \bd with \nd as a conflicting marker removes \nd but
never applies \bd while reporting success.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: note EditorRef suites in every editing view (PT-4197)

noteIndexAndHighlight, applyUpdateDomSelection and noteLooseTextApply
mount through optionsInView(view) in Standard, formatted and
paragraph-structure. The one row whose premise is Standard's display of
an unclosed note's caller (plain caller text, not an immutable caller
element) stays editable-marker only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: paragraph split and deletion content rows in every editing view (PT-4197)

charStackParagraphSplit and paraWholeDeletion pin the marker-edit
engine on hand-built Standard trees. Each gains real-Editor rows over
Standard, formatted and paragraph-structure judged on the saved USJ:
Enter inside \nd (and inside \wj \+nd) keeps both halves styled, and a
deletion of a paragraph's text, or across a paragraph boundary, keeps
the content the selection did not cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: word the navigation row-isolation comment as the hazard it is (PT-4197)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: satisfy the typecheck in two per-view rows (PT-4197)

The popover suite's known-failure map is a plain string map, and the
deletion rows' Delete keypress checks the editor root for null itself
(the marker-edit requireDefined admits null).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: never save an empty span's placeholder or drop an authored `~` (PT-4391)

Follow-up review fixes to the lone-NBSP content rule outside Standard view.

- CharNodePlugin: merging adjacent same-attribute glyph-less spans no
  longer carries an empty span's placeholder into the merged content.
  Landing after a nested span it read as content (the regression), and
  after a text-ending or `~` span Lexical merged it into the text before
  (`x~`, `~~`). The empty side now gives up its children and a caret on
  its placeholder moves to the seam. An author-marked `~` is never
  treated as a placeholder.
- Remove-style ($removeCharNodeKeepingContent) unwraps an author-marked
  lone `~` instead of deleting the span with it.
- NoteNodePlugin stripped a leading NBSP from any note char text on any
  transform (`\ft ~x` saved `\ft x`; typing after `\ft ~` lost it). It now
  uses CharNodePlugin's placeholder rule, which drops only a byte typed
  over a span that held nothing but the unmarked placeholder.
- $isCharContentEmpty counted any NBSP-only text as empty, so a
  close-and-reopen after a `~` span's `~` dropped the span with its `~`
  (unformatted, formatted, paragraph-structure). It now strips only the
  separator and asks $isLoneNbspContent about a lone NBSP.

Tests: gesture-level guards through the real Editor in every view
(lone-nbsp-gestures), CharNodePlugin merge/typing and NoteNodePlugin
units, $isCharContentEmpty units, and live/serialized agreement rows
inside spans and notes (read-only glyphs, collapsed-caller notes,
author-marked nodes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: preserve the USX/USJ version end to end (PT-4197)

A document now leaves the converters and the editor at the version it
arrived at, instead of being re-stamped 3.1 and forced back to 3.0 by the
host.

- `usxStringToUsj` keeps the root `<usx version>` (USJ_VERSION only when the
  USX declares none or an empty one); `usjToUsxString` writes `usj.version`
  (USX_VERSION only when the USJ has none).
- `Usj.version` is widened from `typeof USJ_VERSION` to `string` — a public
  type change, recorded in the utilities CHANGELOG and api report.
- The `usxVersion` option on `usjToUsxString` (Unreleased only) is removed;
  the round-trip audit suites now round-trip the version with no help, and
  the testUSFM USJ -> USX -> USJ net compares whole documents, version
  included.
- The editor remembers the version of `defaultUsj` / each `setUsj` and
  stamps it on every USJ it builds (load, local commit, apply, stale
  refresh, settled read via `$settledUsj`). The reverse adaptor takes the
  version to stamp; internal scratch USJ fed to the forward adaptor is
  unchanged. Loading a non-USJ_VERSION document no longer warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Swallow Enter inside an expanded note where markers are not editable

Outside Standard view nothing turns Enter in a note into a \fp break, so
Lexical split the paragraph holding the note and left the caret outside
it; what was typed next was lost on Save. NoteEnterGuardPlugin claims
KEY_ENTER, INSERT_PARAGRAPH and INSERT_LINE_BREAK at critical priority
while the selection touches an expanded note, in every markerMode but
"editable".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(platform-editor): save no space typed right after a char opener, as Paratext 9

Standard view saved a space typed directly after a char span's opener as
content (`\nd  things\nd*` -> content " things"). Paratext 9 skips all
whitespace after any opening marker (UsfmToken.Tokenize), so its Standard
view saves `\nd things\nd*`, and so does ParatextData on Platform.Bible's own
save path (ConvertUsxToUsfm runs NormalizeUsfm) — the editor's getUsj() was
the only place the space survived, so the editor reported a document the
file never held.

The span's content now drops its leading plain spaces on save, the rule a
paragraph's start and the text after a verse number already follow: the
screen keeps the typed space and the caret after it; the file does not get
it. The logical position model drops the same spaces so positions agree with
getUsj(). A span holding nothing but spaces keeps them (a selected space
wrapped in a span), since dropping that would join the words around it.

Catalog row O-5 (PT-4197). Pins updated deliberately: typedSeparatorSpace,
separatorGapSettle, verseAdjacentTyping, charAttributeTypedSettle (Standard
only; unformatted keeps whitespace), the adaptor caller-lookalike case, the
logical-model unit and oracle tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(platform-editor): report the space before a verse after a char span while pending

When a settle creates a verse directly after a char span (e.g. a `\` typed in
front of `v 2 b` after `\w grace\w*`), TextSpacingPlugin's verse transform
puts a space between them once the verse lands. The read-only settle behind
a pending getUsj() creates no live nodes, so it reported the span and the
verse touching, then the same bytes came back spaced after the settle.

The read-only settle now mirrors the transform over the output of every
paragraph or chapter scope it re-tokenizes. A scope the live settle only
renames in place (signature unchanged) re-creates no verse, so it is left
alone on both sides.

Paratext 9 has this space too: UsfmToken.Tokenize inserts one between a
closing marker and a following \v on every read, and the file is the same
with or without it (the writer puts a line break before \v) — measured with
ParatextData 9.5.0.24. Catalog row O-7 (PT-4197).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(platform-editor): rebuild committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep pasted prose inside the char span or note it is pasted into

Lexical inserts a paste's nodes (internal flavor, or text/html, which is what
Ctrl+V delivers) with RangeSelection.insertNodes, which splits every inline
ancestor of the caret up to the paragraph: text pasted inside \nd Lord\nd* split
the span and landed between the halves, and text pasted inside a note landed in
the paragraph after it (cut + paste back inside a note moved the text out).

A SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler now inserts a payload of plain
prose as text when the paste starts in ordinary text inside an inline element,
the way typing it would. Structured payloads are still Lexical's.

Found by the PT-4197 clipboard corpus sweep (finding CB-2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the no-break space a cut leaves as a char span's only text

In the views without char glyphs a span's lone NBSP is also the empty-span caret
placeholder, which the save drops unless the text is tagged as the author's. Only
the USJ-to-editor adaptor tagged it, so cutting or deleting `trail` from
`\nd trail~\nd*` saved an empty span: the NBSP outside the selection was lost.

A CharNodePlugin transform now tags a lone-NBSP text in a glyph-less span as
authored when that text held more than the placeholder in the last committed
state.

Found by the PT-4197 clipboard corpus sweep (finding CB-4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sweep copy, cut and paste over the testUSFM corpus in every editing view

Standard view's USFM clipboard has a corpus sweep; formatted and
paragraph-structure had none. The new suite samples whole verses, ranges
starting or ending inside a char span, ranges holding a footnote, crossing a
paragraph boundary or a verse number, spanning a paragraph, and inside note
content, then copies and pastes (all flavors, html+plain as Ctrl+V delivers,
plain), cuts, cuts and pastes back, and pastes USFM-looking text, checking each
result and its undo/redo against a USJ-level model.

Pinned with knownFailureRunner (68 rows):
- paragraph-structure: a copy across a paragraph boundary carries the gutter
  glyph (\q1) in text/plain, so CommandMenuPlugin refuses its paste (CB-1);
- hidden views, html flavor: char attributes (closed) are not carried and a
  note's display spacers come back as text (CB-3);
- Standard reference rows over damaged USFM (2SA-5) and one derived-sid loss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rebuild the committed dist for the clipboard fixes

Carries the inline-prose paste and the remaining-NBSP transform into
packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: typing-fidelity oracle harness for the hidden-marker views (PT-4197)

A keystroke oracle for formatted and paragraph-structure, where no
pending marker edit exists to settle. The expectation for each keystroke
is computed from the loaded USJ alone: each leaf block read as units (a
character, or an atom shown whole: verse, collapsed note, milestone,
figure), a caret as a unit index, and typing, Backspace, Delete, Enter
and typing over a range edited into the USJ tree there. The saved USJ
must be one of the expected documents, Undo must restore the loaded USJ
exactly and Redo re-apply the keystroke's.

Keystrokes arrive as a browser delivers them: the caret placed with a
selection change, the DOM selection made the editor's, a real keydown
on the root, then beforeinput and the text insertion. Selection.modify
(which jsdom lacks and Lexical's deleteCharacter needs) is supplied at
character granularity. Failures are reported and listed by shape.

Exports the pending-equals-settled oracle's keystroke corpus for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: Delete before a collapsed note no longer throws (PT-4197)

In the hidden-marker views, Delete just before a collapsed note removes
its caller; NoteNodePlugin then removed the note and went on to move its
first child out of the removed node, which threw ('Expected node to have
a parent'), discarding the edit. Return once the note is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: joining a paragraph into an empty \b keeps its text (PT-4197)

ParaNodePlugin refuses text typed into an empty blank line by clearing
it. A join with the paragraph after it (Backspace at that paragraph's
start, Delete in the blank line, or a range across the break typed over)
also lands content in the empty \b, and the clear deleted that whole
paragraph's text in the formatted view. Clear only when the paragraph
after the blank line is still there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: Undo takes back Enter in front of a char span or a note (PT-4197)

Lexical's history folds an update whose only dirty leaf is a text that
kept its text into the previous undo step, even when tagged as a push.
A paragraph break in front of an inline element is such an update in the
formatted view, so Undo did not take the break back. ParaNodePlugin now
marks a created paragraph's first leaf dirty as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: an author's no-break space left alone in a span is saved (PT-4197)

In the views without a span separator (hidden and visible markers), a
lone NBSP in a span is the empty-span placeholder unless it carries
authoredNbspState, which the adaptor set only on a span text that was
exactly one NBSP at load. Deleting the rest of \nd ~x\nd* (or x~)
left an unmarked NBSP, and the span saved empty; an NBSP typed into an
empty span lost to the placeholder drop the same way.

The adaptor now marks every span text holding an NBSP (Lexical keeps
node state through text edits and copies it on splitText), and the
placeholder drop marks the NBSP it leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: typing over a range across a span edge or a note keeps the rest (PT-4197)

Lexical's RangeSelection.insertText moves the content after a range's
last text to after its first text, into the inline element the range
starts in, and resolves a range start at a text's end into the inline
element after it. In the hidden-marker views that pulled the text after
an enclosing char span inside the span (out of order), and, for a range
starting just before a collapsed note, put the typed text and the rest
of the range into the note, which was then removed with them.

InlineRangeEditPlugin (mounted for markerMode hidden) replaces a range
over more than one text of one paragraph itself: it cuts the edge texts,
removes the nodes between and the spans and notes that leaves empty,
and puts a point Lexical resolved into a collapsed note back beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the typing-fidelity oracle in formatted and paragraph-structure (PT-4197)

Two files per view (the keystroke corpus with the short testUSFM
chapters; the long testUSFM and WEB chapters), paragraph-structure under
Simple mode's guarded structure protection. The shapes still failing are
listed with their reasons: Redo lost or a second Undo needed around an
empty verse, one Enter-at-paragraph-start Undo in WEB Matthew 5, and one
ignored Backspace at an element caret in 1 Samuel 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: rebuild the committed platform output (PT-4197)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(platform-editor): rebuild committed dist for the typing and clipboard merges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test(platform-editor): hold each keystroke to keeping content, and pin separator removal

The pending/settled oracle compared the pending document with the settled
one, so content a transform dropped in the keystroke's own update was
invisible: the pending document already lacked it and settled to itself.
A mutation that removes a span's following text once its separator is
deleted passed every check. Each edit's screen is now read as the edit
left it (before transforms) and as it landed, and the landed screen may
only respell the spliced one: no content byte (whitespace and marker
tokens aside) may go. Range keystrokes are excluded: the oracle replaces
a range with a direct insertText that skips the commands the marker-edit
engine answers, so their transforms are not what a keystroke does. All
four oracle views stay green with nothing new listed.

Catalog row O-8 (PT-4197) reported Backspace after a `\w` opener's
separator losing the span and the text after it. It does not: the bytes
on screen (`\p In the \wgrace|grace\w* of God`) settle, pending and
settled alike, to `\p In the ` and a new unknown paragraph `\wgrace`
holding the rest — ParatextData's own reading (an unknown marker is a
paragraph outside a note, a char span inside one; checked against its
USX). The report came from a probe that printed only the first block.
New cases pin it for Backspace and Delete across attributes, `\nd`,
nesting and a note, comparing the whole document with ParatextData's USX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(platform-editor): save a char span holding only spaces as empty, as Paratext 9

Standard view already dropped the spaces after a char opener's separator,
but kept them when they were all the span held (a selected space wrapped
in `\nd`). ParatextData skips all whitespace after an opener there too:
`one\nd  \nd*two` saves as `one\nd \nd*two` (USX `<char style="nd" />`),
so the words join in the file. The editor's saved USJ now matches; the
screen still shows the space inside the span. The logical position model
drops the exception with it, so positions agree with getUsj().

A no-break space is data and stays: `\nd ~lead\nd*` and `\nd ~\nd*` keep
it on load and with a space typed in front of it (new tests; ParatextData
keeps both too).

Pin updated deliberately: markerMenuApply's whitespace-only wrap now saves
`["one", {nd}, "two"]` while the live span still holds the space.

Catalog row O-5 follow-up (PT-4197).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform-editor): rebuild committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(shared-react): keep alike char spans apart once a deletion leaves them side by side

In the hidden-marker views (formatted, paragraph-structure) CharNodePlugin
combined two adjacent same-marker, same-attribute spans whenever either was
dirtied, so deleting the text between `\w one|lemma="x"\w*` and
`\w two|lemma="x"\w*`, or joining the paragraphs they end and start, saved one
`\w onetwo\w*` and changed word segmentation (typing oracle finding T-7).
Standard view keeps them apart (its spans carry glyphs), and so does
Paratext 9.

Two spans that were already alike, separate spans in the last committed state
now stay separate. Spans an update creates (a marker applied to a run, a
split's continuation, remote content) still combine with an alike neighbor,
as does a span whose marker an update changes to match.

- Regression tests in every editing view plus paragraph-structure (guarded):
  deleting the space between, and joining the paragraphs.
- CharNodePlugin unit tests that expect a combine now add their spans by an
  update once the plugin runs (they previously relied on the plugin's
  registration re-dirtying the initial state, i.e. spans the document already
  held); new unit test for the keep-apart case.
- Typing oracle: drop the "adjacent alike spans are one" allowance; both views'
  corpus and chapters pass without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform-editor): rebuild committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test(clipboard): name the tickets that fix the clipboard corpus pins

The paragraph-structure gutter-glyph copy pins (CB-1, 44 rows) name
PT-4911, and the hidden-view html-flavor pins (CB-3, 13 rows) name
PT-4921, in a TODO beside each rule and at the head of its reason. The
Standard-view damaged-USFM and derived-sid rows have no ticket yet and
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(shared-react): arm a chapter for the guarded two-press delete

Under "guarded" structure protection (Simple mode) a chapter marker beside the
caret was outside the two-press guard: Delete at the end of the block before
`\c N` removed the chapter on the first press with no arming, and Backspace at
the start of the paragraph after it armed that paragraph, whose merge then
did nothing because a paragraph cannot merge into a chapter (typing oracle
finding T-10).

$structuralDeleteTarget now resolves the atomic chapter glyph
(ImmutableChapterNode) beside a block edge as a "chapter" target. The first
press node-selects it (as for a verse) and the armed chapter carries the
verse-selected class, so it shows the armed styling; the second press removes
it and returns the caret where the first press found it. The opposite key
cancels. No host hint kind is published for a chapter (the host's hint copy is
verse/selection-only), matching paragraph merges. Standard view (editable
chapter text), "protected" (still refused) and "off" (Power mode) are
unchanged.

Tests: guardedChapterDeletion.test.tsx in formatted and paragraph-structure
views - Backspace after / Delete before `\c 1` and `\c 2` arm then delete
(guarded), cancel on the opposite key, refused under protected, native under
off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(platform-editor): keep a nameless character marker's empty name on export

ParatextData reads a bare `\` inside a note (`\f + \ft \ b\f*`) as
`<char style="" status="unknown">`. The converters already keep the empty
style as `marker: ""`, but the editor's export turned an empty char marker
into no marker at all, so getUsj() handed back a char with no name and the
USX written from it was not the USX loaded. createCharMarker now keeps
`marker: ""`, the way nameless paragraphs already round-trip.

A char with no marker at all in its USJ (not a shape USX can express) now
exports as `marker: ""`, as an absent paragraph marker already exports as `p`.

Tests: namelessChar.test.tsx loads ParatextData's captured USX for an unclosed
and a `\*`-closed nameless span in a footnote, and asserts getUsj() and the
USX written from it are unchanged in Standard, formatted and
paragraph-structure views; converter tests for a char's empty style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* docs: correct what ParatextData does with a marker-bearing \cp

Comments claimed ParatextData keeps every `\cp` that holds a marker as its own
marker. Measured, it folds the text before the first marker into the
chapter's pubnumber and leaves the rest as loose content after the chapter;
only a `\cp` whose content starts with a marker stays a `\cp`. The editor
deliberately settles such a `\cp` to the USFM-spec shape: the saved bytes are
the ones Paratext 9 writes, and a reload shows Paratext 9's shape. No
behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform-editor): rebuild committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* feat(shared-react): publish a chapter hint kind for the guarded delete

An armed chapter in Simple mode's two-press delete now sets
data-verse-delete-kind="chapter" (with data-verse-delete-intent) on the
editor root, as an armed verse or selection does, so the host can show a
chapter-specific "press again" hint. A paragraph merge still publishes no
hint kind. CHANGELOG records the chapter arming and the new value.

Tests: guardedChapterDeletion (formatted + paragraph-structure) and the
StructureKeyboardPlugin DOM-signal suite expect kind=chapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test(platform): Standard view may drop an empty derived sid on a verse paste

Pasting `\v 9 x` as plain text in Standard view rebuilds part of the
paragraph it lands in, and the 2SA-3 fixture's empty derived `sid`s on the
re-derived verses do not survive. Dropping derived metadata is accepted
(data-fidelity catalog F-8), so this is now an expectation rather than a
known failure: in the paragraph the verse lands in, an empty `sid` may be
dropped, and everything else (non-empty `sid`s included) must still match
exactly. The row still performs the paste, undo and redo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test: name the tickets for the remaining untracked expected failures

- PT-4924: typing-oracle T-11 / T-12 / T-16 entries in both views'
  expected-failures JSON (in each reason).
- PT-4925: Standard view's extendCharacterMarker coalesce/conflict rows
  (EXTEND_KNOWN_FAILURES).
- PT-4388: the Standard-view 2SA-5 damaged-USFM clipboard rows and the
  fragment tokenizer's "testUSFM 2SA 5" known divergence (bare `\` as a
  nameless marker, waiting on ParatextData 9.6).

Every pin and what it asserts is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix: a join into an empty \b keeps the joined paragraph, as Paratext 9 (PT-4197)

Backspace at the start of a paragraph after an empty \b, Delete in the
blank line, or a range across the break typed over joined the paragraph
into the \b and kept the blank-line marker on text. Paratext 9's
formatted view joins paragraphs the way Gecko joins two blocks: the
second block survives (JoinNodeTransaction keeps the right node), so the
blank line goes and the paragraph keeps its own marker. ParaNodePlugin
now gives a blank \b that takes in a joined paragraph that paragraph's
marker and attributes, rewriting a gutter glyph to match, in formatted,
paragraph-structure and Simple's guarded two-press join. Standard view
edits marker text and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test: a typed U+00A0 saves as a space in Standard view, as Paratext 9 (PT-4197)

Paratext 9's Standard view (AllowInvisibleChars off) saves every U+00A0
in the editor as a space (UsfmHtmlUtilities.LegacyPostprocessUsfm) and
the author's no-break space is typed as `~`. Ours does the same; pin it
between letters, after a space and at a span's start, and pin `~` typed
saving an NBSP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build: rebuild committed dist for the \b join fix (PT-4197)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(shared-react): paragraph-structure Enter drops the break's space

Enter at a word break (`In| the`) left ` the` in the new paragraph in the
paragraph-structure (Simple) view, while Standard and formatted save
`the`. ParaNodePlugin trims a new paragraph's leading space from its first
child, which in that view is the gutter marker glyph rather than the text,
so nothing was trimmed. It now looks past a leading gutter glyph.

Tests: typingFidelity.regressions "Enter at a word break" in all three
editing views, unguarded and guarded (red in paragraph-structure: saved
" the beginning"). The typing oracle no longer accepts a new paragraph
that starts with text keeping its leading space (it fails 23 corpus rows
without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* feat(platform): ViewOptions.allowInvisibleCharacters keeps ~ and U+00A0 apart in Standard view

A Paratext project with AllowInvisibleChars on stores a no-break space as
U+00A0 itself and `~` as an ordinary character (measured with ParatextData:
load, save and round trip all carry both verbatim and distinct). Standard
view always applied the `~` = NBSP rules, so a literal `~` in such a
project was read as a no-break space and saved as U+00A0, and a U+00A0
typed there saved as a space.

With the new option on, a stored U+00A0 displays as NBSP_SYMBOL (U+237D)
instead of `~`, `~` stays an ordinary character, and every layer that
maps display or fragment text to data takes the spelling from
getNbspSpelling(viewOptions): the load/save adaptors, the tier-2 and
virtual settle tokenizer (new UsfmFragmentOptions.nbspSpelling), the
Unformatted content fragments, paste, copy (U+00A0 written as itself),
the Markers view's copy, and a typed U+00A0 (command and native
beforeinput routes). getViewMode ignores the field. Off is unchanged.

Tests: invisibleCharacters.test.tsx (load/save, edit, span and note,
typed ~, typed U+00A0 by both routes, paste, copy, settle and unsettled
read, Unformatted settle, Markers copy, formatted/paragraph-structure
pins), tokenizer nbspSpelling, getViewMode/getNbspSpelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* feat(platform): toUsfmNbspSpelling for hosts that read the editor's display text

A host that copies or searches text read from the editor's DOM (the
footnote popover's Copy button, a Find seeded from the selection) sees
Standard view's display spelling of a no-break space. With
allowInvisibleCharacters on that is U+237D, which is not the project's
text; this turns it back into U+00A0, and leaves the text unchanged when
the option is off (`~` is the project's spelling then).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* test(platform-editor): keep a note's empty caller empty through edits in every view

ParatextData reads `\f \ft x\f*` (one or two spaces after the marker) as
caller="" and writes it back as `\f \ft x\f*` (measured with the corpus
capture harness), and the tokenizer has followed that since PR #11. The
existing rows covered only the expanded editable views; this pins a
load, an edit beside the note and one inside it, and a departure in
every editor view, saving caller "".

Fails in all 8 views if the exporter writes "+" for an empty caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* feat(platform): U+001A is the no-break-space stand-in, drawn as a blank, and never leaves the editor

With ViewOptions.allowInvisibleCharacters on, Standard view held a stored
U+00A0 as U+237D (shown as a visible box glyph). U+237D can occur in
Paratext 9 text, so a literal one was saved as a no-break space. It now
holds U+001A (SUBSTITUTE), which ParatextData's ScrText.GetText turns
into a space on every read, so no project text can hold one.

- Rendering: a one-glyph font (empty, 0.25em wide, unicode-range U+1A,
  NBSP_STAND_IN_FONT_FAMILY) draws it as a blank the width of a space.
  The editor adds the face to its document through the CSS Font Loading
  API (from bytes, so no CSP font-src is involved). Chromium draws a
  control character as a box unless the FIRST family has a glyph for
  it, so generateUsjCss and usj-nodes.css lead every Scripture
  font-family with it.
- It never leaves the editor: delta ops given out (onUsjChange,
  getNoteOps, getOpsAfterNote, takeOpsAfterNote) carry U+00A0 for it and
  a space for an NBSP the view shows in a space run; applyUpdate and
  replaceEmbedUpdate take a U+00A0 that is not a marker separator as a
  stored no-break space; the internal Lexical clipboard flavor is left
  out when the selection holds a stand-in; a drag out carries the copy's
  USFM text and html; annotation callbacks and note-caller previews give
  U+00A0.
- Incoming text: every C0 control character but tab, LF and CR becomes a
  space in Standard view before anything reads it (load incl. attribute
  values, paste, drop, typing by both routes, applied ops).
- A drop into Standard view now goes in as a paste of the same data.
- The fragment tokenizer keeps the spelling as a character
  (regularizeSpaces turned control characters into spaces first).

Leaking before (measured against the U+237D build): ops out, note ops,
the footnote-editor round trip, ops in (U+00A0 saved as a space), the
Lexical clipboard flavor, the cut, the async paste, drag and drop, and
annotation text. Forced-on run (every registry view with the option):
only the 5 intended off-mode pins fail.

Known gap (both settings, not changed here): an attribute value's
U+00A0 is shown raw in its attribute run, and a settle of its paragraph
reads it as a space.

Tests: nbspStandIn.test.tsx (35), whitespaceDisplay.utils.test.ts
(control characters, ops mapping), usj-nodes.css and generateUsjCss
font stack, invisibleCharacters.test.tsx on the new stand-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(platform): a note caller's click hands out the note's ops with U+00A0

The getNoteOps a noteCallerOnClick handler receives read the note's ops
straight from the tree, so with allowInvisibleCharacters on they carried
the internal stand-in; Platform.Bible's footnote popover applied them,
and the incoming rule turned each stand-in into a space (seen in the
running app). They now go out through opsForHost like every other op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* Address review-paratext findings on the data-fidelity branch

- Fix InlineRangeEditPlugin: typing over a range whose two edge texts are
  both wholly selected no longer falls through to Lexical, which pulled the
  text after a char span into the span. The plugin now steps aside only for
  a whole-edge deletion. Regression rows added to
  typingFidelity.regressions.test.tsx.
- Add standardViewDrop.test.tsx: drop-as-paste rows under both
  allowInvisibleCharacters settings.
- Add the missing CHANGELOG entries (platform Fixed section; utilities note
  that the Scribe editor does not yet carry the USJ version through).
- Replace in-code Jira/branch references with forward-facing wording per
  CLAUDE.md, keeping open TODO(PT-x) markers.
- Document that allowInvisibleCharacters must be right before the first
  load; correct the vertical-tab/form-feed wording; clipboard-semantics
  doc updates.
- Cleanups: $collapsedNoteOf uses $findMatchingParent, ParaNodePlugin key
  lookup, CharNodePlugin TSDoc, un-export $isSelectionInExpandedNote,
  NoteEnterGuardPlugin ordering comment, corpus fixed-point test uses
  knownFailureRunner, reshaped half-flat test trees, comment re-wraps,
  demo armed-chapter CSS.
- Rebuild committed packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_01KiNAWjh4KAwjzn1g34EePL

* fix(platform): an attribute value's no-break space keeps its spelling in Standard view

Standard view showed a U+00A0 inside an attribute value as a raw U+00A0
in its attribute run, which the engine reads as display spacing, so a
settle of the paragraph (Tier-2 rebuild or virtual settle) saved it as a
plain space, in both ViewOptions.allowInvisibleCharacters settings. The
value now takes the body text's spelling (`~`, or the U+001A stand-in
with the setting on) everywhere attribute state becomes display text:

- the adaptor's runs for char spans, milestones, \va/\vp, \cat, \ca/\cp
  and an UnknownNode's display parts (figure src, sidebar \cat, periph);
- the display-run syncs, which redraw from node state without view
  options: MarkerEditPlugin registers the editor's spelling
  (registerNbspDisplaySpelling / $getNbspDisplaySpelling in shared);
- the `|...` bytes an unwrapped or emptied span leaves as content, a
  collapsed note's copied \cat, and the annotation byte maps.

The tokenizer reads the spelling back as U+00A0 inside milestone
(including an absorbed list), figure and \periph attribute values, as it
already did for body text, char-span attributes and the attribute
markers. Unformatted view, which shows a value's U+00A0 as itself, now
respells it past the run's leading separator in its settle fragment
(attributeFragmentText), as it does content, instead of flattening it to
a space.

Tests: attributeValueNbsp.test.tsx (each producer shape in Standard view
with the option off and on: display, paragraph settle, value edit read
before and after the settle, copy and paste with every flavor and with
plain text alone; figure; span unwrap and paragraph-break emptying; a
collapsed note's copied \cat; Unformatted; formatted and
paragraph-structure pins) and tokenizer cases in
usfmFragmentToUsj.test.ts. The nbspStandIn chapter row now settles the
chapter line instead of pinning the raw display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* build(platform): rebuild the committed dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDpA6DiFYGheLpPSaj6yPz

* fix(shared-react): rejoin a char span that Enter split when Backspace joins the paragraphs back

Keeping alike char spans apart once both are in the document also kept apart
the two halves of one span that Enter cut in two, so Enter then Backspace
inside `\nd thing\nd*` saved `\nd th\nd*\nd ing\nd*` in the views that hide
markers.

- CharNode.insertNewAfter records, per editor, which span each split
  continuation came from; $wereSplitFromOneSpan lets those pieces merge again.
  Spans the document held apart still stay apart.
- Regression test in every hidden-marker view: Enter inside a span, then
  Backspace, saves one span.
- InlineRangeEditPlugin: comment the composition gap (an input method started
  over a cross-span range takes Lexical's own path).
- CHANGELOG entry extended.
- Rebuild committed packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_018WCZ5KDNh1Vqzt3JP6jCnp

* fix(shared-react): keep Undo/Redo working around empty paragraphs and caret hosts

- Undo after a guarded two-step removal of an empty paragraph (an empty
  `\b`) now brings it back. Lexical's history folds an update whose only
  dirty leaf is a text that kept its text into the step before, even one
  tagged history-push, so `$mergeParaIntoPrevious` marks the removed
  paragraph's leaves dirty to make the removal its own undo step.
- Redo works after an Undo leaves the caret beside an empty verse or a
  trailing note. Caret-host commits (create, move, removal on blur) are
  tagged history-merge through `$tagCaretHostCommit`, so they no longer
  become an undo step that clears the redo stack.
- Drop the testUSFM-2SA-2/2SA-3 rows from both typing-oracle
  expected-failure lists: the Undo/Redo shapes they pinned now pass.
- Pin an author's literal `~` in a milestone attribute value turning into
  U+00A0 on a Standard-view settle with `it.fails` (needs a product call).
- Use a code span for `USJ_VERSION` in `Usj.version`'s TSDoc so
  consumers' typedoc can resolve the comment.
- Rebuild the committed dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session-URL: https://claude.ai/code/session_018WCZ5GnNin2H8EzQyWWq6p

* fix(platform): keep a pasted no-break space at the start of a line

Standard view's paste read a U+00A0 at the start of the pasted text, or
of any line of it, as display spacing and inserted a plain space. So a
pasted lone no-break space became a space, one beside a space collapsed
away, and with allowInvisibleCharacters on the editor's own copy of a
stored no-break space pasted back as a space or as nothing (the copy
spells a stored no-break space as U+00A0 there).

No copy reaching the paste puts display spacing at a line start: the
editor's own copy writes a plain space for every display NBSP in both
readable flavors, a browser's default copy writes text/plain with every
NBSP as a space, and a Paratext 9 clipboard is decoded with its NBSPs
resolved. normalizePastedNbsp now keeps every pasted U+00A0 as a stored
no-break space except one right beside a marker token, which is still
read as a separator.

pastedNbsp.test.tsx pastes each case through the editor in both
settings: from a foreign text/plain and html-only clipboard, and the
editor's own copy pasted back. Two rows stay expected failures with the
setting on: a stored no-break space right after a closer or right
before an opener copies as U+00A0 and pastes back as a space under the
marker-adjacent rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(platform): stop exporting NBSP_STAND_IN_FONT_FAMILY

Hosts name the no-break-space stand-in's font family in their own
stylesheets, which cannot import a constant, so the export gave them
nothing to use. The constant stays internal to generateUsjCss and the
font registration, and the docs now state the family name,
PlatformEditorNbspStandIn, which must lead any host Scripture
font-family and must not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep an author's lone no-break space in delta ops

- The delta-op producer dropped every text that is exactly one U+00A0
  inside a note's contents, and every char span whose only text is one
  U+00A0 (as an empty span's placeholder). In views that hold a stored
  no-break space as U+00A0 itself (formatted, paragraph-structure), that
  is also a `~` between two of a note's char spans, or a `~` that is a
  span's whole text.
- Both checks now ask `$isLoneNbspContent`, the rule the editor-to-USJ
  conversion already keeps content by, so a note's ops (`getNoteOps`,
  which Simple mode's footnotes pane edits) carry what `getUsj()` saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(platform): Standard view's delta ops read as USJ text in both no-break-space settings

- `opsForHost`/`opsFromHost` told a marker's separator from a stored
  no-break space only within one op, and took any op that is exactly one
  U+00A0 as a spacer. The editor emits each text node as its own op, so a
  glyph's separator (`\nd`, then ` A`) went out as a space, and with
  `allowInvisibleCharacters` on a stored no-break space that is a whole
  op's text (a `\ft` that is only `~`, a `~` between two spans or after a
  note, text carried out past a note) came back in as display spacing and
  was saved as a space or dropped. An NBSP is now structural only right
  after an opening glyph, in the same op or ending the op before.
- With `allowInvisibleCharacters` off, Standard view's ops went out as its
  display text: `~` for a stored no-break space and U+00A0 for each space
  of a run. Simple mode's footnotes pane edits a paragraph-structure
  editor's notes in Standard view, so a pane edit saved a space run as
  no-break spaces, and a stored no-break space showed in the pane as a
  blank rather than `~`. Both settings now map the same way: the stored
  no-break space's spelling to U+00A0 and a run's NBSPs to spaces going
  out, U+00A0 to the spelling coming in.
- A paragraph-structure editor still reads a char span whose whole text
  is one U+00A0 from applied ops as an empty span's placeholder, so the
  pane drops that `~`; pinned with `it.fails`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(platform): mark toUsfmNbspSpelling experimental

It stays exported, but is expected to be removed once Standard view
holds a stored no-break space as itself (PT-4931): display text will
then already carry the project's spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep an applied no-break space that is a char span's whole text

- Applying delta ops built every char span's text without the mark a
  load puts on a span's no-break space (`authoredNbspState`). In the
  marker modes without a separator after a span's opener (formatted,
  paragraph-structure), a span whose whole text is one U+00A0 then read
  as an empty span's placeholder and saved as nothing: Simple mode's
  footnotes pane dropped a `\ft ~` (or any `~`-only span) its row editor
  handed back, and the same happened to a `~`-only span inserted by
  `applyUpdate`.
- The op producer leaves an empty span's placeholder out of its ops, so
  an applied U+00A0 is always content. `$markAppliedNbsp` marks the text
  of every char-span insert (`$handleCharText`, a note's contents, an
  unknown embed's contents) that holds one, as the USJ adaptor marks a
  loaded span's text; editable marker mode is left unmarked, as on a
  load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(platform): expect the footnotes pane to keep a `~`-only char span

The paragraph-structure main editor now marks an applied lone no-break
space as content, so the row that pinned the drop with `it.fails` runs
as an ordinary test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep a char span or milestone in text carried out past a note

Text a host's note editor carries out past its note (`takeOpsAfterNote`,
applied after the note with `replaceEmbedUpdate`) lost its markup:

- A char span from a Standard-view note editor became an unclosed span
  plus a stray `\nd*` in Standard view, and its glyphs became span text
  in the paragraph-structure and formatted views. The carried ops held
  the span's marker glyphs as text, the shape of the note editor's own
  delta document. `getOpsAfterNote`/`takeOpsAfterNote` now produce
  content-only ops (`$getParticularNodeOps`'s new `contentOnly` option),
  the shape a note's contents already have, and `takeOpsAfterNote`
  deletes what it carries by the nodes' apply-coordinates length
  (`$getApplyLength`), since the ops no longer count the glyphs.
- Applying a char span in editable marker mode added its glyphs but
  advanced past its text alone, so the ops after it landed inside the
  span's opener. `$handleCharText` now gives a fresh span the separator
  after its opener, as a load does, and counts everything it added.
- Plain text inserted at the end of a nested span went into the outer
  span; it now goes after the outermost span.
- A milestone vanished in every view: the op producer writes a milestone
  under `milestone`, and the applier read only `ms`. It now reads both.

Standard view still splits a carried `\nd L \+wj M\+wj*\nd*` into two
`\nd` spans, pinned with `it.fails` in noteCarriedText.test.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(platform): pin copying a whole note through the editor's own copy

A host's copy-this-note button selects the whole note, calls
EditorRef.copy() and puts the selection back with setSelection(). Pin
that the sequence writes the USFM a Ctrl+C of the note writes (no
display NBSP, a stored NBSP as ~ or U+00A0 by setting, no closer an
unclosed note lacks, no text after the note), applies the copy limit
(a note that does not fit copies nothing), and leaves the selection,
the document and a pending marker edit as they were when it runs in
one synchronous handler. The mount helper takes a copy limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(platform): read a pasted no-break space as a space only after an opening marker

A paste turned a U+00A0 right after any marker token, or right before
one, into a space. With allowInvisibleCharacters on, Standard view's own
copy writes a stored no-break space as U+00A0, so copying one that sat
after a closer or in front of a marker and pasting it back saved a
space.

Only a U+00A0 directly after an opening marker (\nd, \+nd) is now read
as a space: the marker's separator, as ParatextData reads it. After a
closer or a milestone's \* self-closer, and in front of any marker, a
U+00A0 is kept as a stored no-break space. A foreign copy of the display
that carries a run's or a note layout's spacing there (the⍽\nd,
\f +⍽\fr) now keeps it as a no-break space rather than a space: nothing
is lost. The two own-copy round-trip rows that pinned the old reading as
expected failures now pass; the paste suites are re-pinned to the new
reading, and a pasted \nd~Lord\nd* is pinned to keep every byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): emit milestone ops under the ms key the op types declare

`$getMilestoneOp` wrote a milestone as `{ insert: { milestone: … } }`, but
`OTEmbedTypes`/`validOTEmbedTypes` declare `ms`, and so do
`isInsertEmbedOpOfType` and the applier. A host reading ops through the
public type guard never saw a milestone, and `getInsertedNodeKey` (which
only recognises `validOTEmbedTypes`) could not resolve one. The producer
now writes `ms`; the applier keeps reading `milestone` too, so ops from
earlier builds still apply their milestones.

Tests that pinned `milestone` (adaptor ops, the shared converter
fixtures) now expect `ms`, the applier's `milestone` test is named as the
legacy-key case, and a produce-then-apply round trip pins the producer
key and that both milestones survive with sid/eid/who. Before the
producer change the round trip's apply half already passed (4e43000f
made the applier read both keys); the key pin failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): build adjacent char ops of different attributes as separate spans

Applying ops joined a char op into the span before it, or into the span
at the insertion point, when the two matched on marker and cid alone. So
the footnote editor's round trip (getNoteOps, applyUpdate,
replaceEmbedUpdate) turned `\w a|lemma="x"\w*\w b|lemma="y"\w*` into
`\w ab|lemma="x"\w*` in every view, and an op inserted right before a
`\w` span of another lemma went into that span's text, losing its own
lemma (and, for a nested char array, its outer span).

`$hasAllSameCharAttributes` (shared) compares the marker, the cid and
every other attribute. `$createNestedChars` and `$handleCharText` use it
to decide whether an op continues an existing span, and
`$charNodeTransform` uses it in place of its own attribute comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep adjacent spans that arrive in one paste apart

In the views that hide marker glyphs, `$charNodeTransform` joined two
alike spans that a paste inserted side by side, so pasting the editor's
own copy of `\w a\w*\w b\w*` or `\nd A\nd*\nd B\nd*` saved one span.
Standard view, and Paratext, keep them as the two spans the copied
content held.

`CharNodePlugin` records the char spans each clipboard insertion brings
in (`SELECTION_INSERT_CLIPBOARD_NODES_COMMAND`, and the protected-document
paste and drop, which insert directly), and `$charNodeTransform` keeps
two of them apart for the rest of that update. A pasted span still joins
an alike span the document held, so pasting a piece of a span inside a
span of the same marker extends it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(platform): rebuild the committed dist for the merged paste and ops fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): count an author's `~`-only char span in delta-doc positions

- The ops stream emits a char span whose whole text is an author's
  no-break space (`$isLoneNbspContent`), but delta-doc counting still
  took every lone U+00A0 that is a span's only child as the empty span's
  placeholder and counted it 0. Every delta-doc position after such a
  span (`\nd ~\nd*` in formatted, paragraph-structure and the other views
  without a separator after the opener) was one short of the ops stream,
  so a fast-path keystroke after it went out with a retain one too small.
- The placeholder check now asks `$isLoneNbspContent`, as the producer
  does. Apply coordinates are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep a note's `~` that follows the opener's separator in delta ops

- In editable marker mode (unformatted) the text after a note span's
  opener is the separator followed by the author's text. For `\ft ~\ft*`
  or `\fk ~\+wj x\+wj*\fk*` that is two no-break spaces; the producer
  stripped the separator and then asked `$isLoneNbspContent` about the
  node, whose whole text is not one U+00A0, so the author's `~` was taken
  for layout and dropped from the note's ops. `getNoteOps` and
  `getEditorDelta` then disagreed with `getUsj()`, which keeps it.
- A lone NBSP left after stripping a separator is now content; the
  position rule is asked only about text that kept its node's every byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): give an applied `~` the separator after a span's opener in editable mode

- `$createNestedChars` told an empty span's placeholder from first
  content by its bytes, so an op whose text is one U+00A0 (an author's
  `~` as a span's whole text, or leading a nested span) got no separator
  after the opener in editable marker mode. Saving then stripped the `~`
  as the separator: in the unformatted view, the footnote popover's save
  and the Simple-mode pane round trip both lost `\ft ~\ft*` and
  `\fk ~\+wj x\+wj*\fk*`.
- The placeholder is now known by where it came from (an empty op's
  text), carried through the merge recursion; every op's own text takes
  the separator. An empty op still gets only the placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(platform): pin that saved USJ and delta ops agree on every lone `~`

- `lone-nbsp-agreement.test.tsx` runs each lone-`~` shape (a span's whole
  text, between two spans, leading an element-first span; in body text
  and in a note) through every view in both settings of
  `allowInvisibleCharacters`: the saved USJ keeps it, `getEditorDelta`
  carries the same lone NBSPs, delta-doc positions after it match the
  ops stream, and a note's `~` survives a save of its own ops, the
  footnote popover's round trip and the footnotes pane's round trip.
  Text typed into an element-first spacer or an empty span's
  placeholder saves as content.
- The popover round-trip suite gains an authored `\fq ~\fq*` and
  `\fk ~\+wj x\+wj*\fk*` through the popover's load and save, in the
  editing views and unformatted.

Tests adapted from Jolie Rabideau's #36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(shared-react): keep a newly marked span apart from an alike span beside it

In the views that hide marker glyphs, applying a character marker to the
text right after a span of the same marker joined the new span into the
old one: the marker menu or `insertMarker('nd')` on "God" after
`\nd Lord\nd*` saved `\nd LordGod\nd*`. Paratext 9 and Standard view make
a second span.

`getUsjMarkerAction` records every span a marker apply creates
(`$recordNewlyMarkedSpan`), and `$charNodeTransform` keeps a recorded span
apart from an alike span the document already held. The pieces one apply
creates still combine with each other; Enter-then-Backspace rejoining,
the op applier's nested fragments, a paste extending a span, and
`extendCharacterMarker` record nothing and keep combining.

A selection that reaches into part of an alike span and past it now
extends that span instead of nesting a copy inside it. This deliberately
departs from Paratext 9, which mangles the line in that case (TJ,
2026-10-27). Standard view kee…
tjcouch-sil added a commit that referenced this pull request Nov 5, 2026
Every annotation paints as CSS classes on a real element, with the class names
a `<mark>` carries (`editor-typed-mark-<type>`, `editor-typed-markOverlap-<type>`,
`annotationId-<id>`, plus `display-annotation` on display bytes); the CSS Custom
Highlight painter and its host-style translator are gone.

- Content text an annotation holds stays in a `TypedMarkNode`; display bytes
  stay held as `displayAnnotationsState` on their carrier.
- An editable display-byte carrier (marker glyph, verse, attribute run, note
  caller, chapter glyph, unmatched closer) is cut into pieces at every hold
  boundary (`displayPieces.state.ts`), each held whole or not at all, and every
  structural reader goes through the piece and through-marks helpers.
- Read-only decorators draw class-carrying sub-spans (`decoratorPaint.ts`).
- Each annotation's first and last painted element get the `typedMarkStart` /
  `typedMarkEnd` edge classes, so a host can draw one box over many elements.
- Settles, copy/cut/paste, collab ops, positions and undo behave exactly as
  with no annotation; the annotation-transparency oracle runs every keystroke
  case plain vs annotated in four views and compares every observable.

This commit squashes PR #26's branch onto main after #11 was squash-merged
(2026-11-04). The branch's own commits follow, oldest first (merges of #11 and
main, and committed-dist rebuilds, left out); the merge fixes against main are
the commits after this one.

==========================================================================
--- 337d2544a6 feat(shared): cut a display-byte carrier into pieces where a hold ends
A carrier an annotation holds in part cannot be painted as one element:
Lexical maps one DOM text node to one TextNode. So the carrier is split at
every hold boundary into pieces of its own class, each held whole or not
at all, and joined again where neighbours are held by the same
annotations. The second and later pieces carry a continuation state; the
chain is read through `$pieces`, `$logicalText`, `$logicalNextSibling`,
`$logicalChildren`, and written through `$setLogicalText`, so a glyph's
canonical check and its setters see one glyph however it is cut.

The piece transform runs beside the hold-basis transform for every
editable carrier class; an unmatched closer, which the basis transform
did not cover, is covered too.

The two basis tests that rewrote a held carrier's text are restated to
write through the whole carrier, because a carrier cut at a hold's edge
is several nodes: the assertion is the same (the hold follows its bytes
through the rewrite), read per piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- bcfc87408b fix(platform-editor): carry annotations across the \id-line settle and a note closed at a typed closer
Both settles re-tokenize their scope and splice fresh nodes in, but neither carried annotations
across the splice, so a comment or check result over the `\id` line's content, a hold on a glyph
there, or anything in a note closed by typing its own closer (its content, its caller, its opening
glyph, and the text the close moves out after the note) was silently dropped. A mark directly in
the line or the note also told the host it had been destroyed.

Both now go through the same byte carry every other settle scope uses
(`$captureMarkByteRanges` / `$restoreMarkByteRanges`), on the mutating and the read-only side, so
`getUsj()` while the edit is pending shows the marks where the settle leaves them:

- `$rebuildBook` captures over the line's content plus any paragraphs it is widened over, and
  restores over the line's content followed by the blocks it starts (`$bookRegionFragment`).
  `$settledBookLine` does the same through `$carryMarksIntoSerializedBookLine`, which materializes
  the line and its following blocks together under a scratch book.
- `$closeNoteAtOwnCloser` rebuilds the whole note, shell included, so it captures over the note's
  shell bytes plus its content (`$withNoteShellFragment`) and restores over the closed note's
  shell, content and closing glyph followed by what the close moved out
  (`$closedNoteRegionFragment`). A collapsed note's caller decorator is spelled as its caller.
  `$settledNoteScope`'s close path carries through `$carryMarksIntoSerialized` with a new
  `"closedNote"` kind.

The scratch-editor half of `$carryMarksIntoSerialized` is factored into
`$carryCapturedThroughScratch` so the book-line variant shares it rather than repeating it. Marks
removed directly with the old line or note children are flagged the way `$rebuildNoteContent`
already flags them, so the host is not told they were destroyed.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 66336da757 test(platform-editor): pin an annotation set on moved-out text while a note close is pending
A host that annotates the text a typed closer moves out of its note, while that close is still
pending, names it where the settled document has it: in the paragraph after the note. The row pins
that the annotation lands on those bytes while pending and stays on them once the close settles,
in Standard view, with expanded notes, and in Unformatted view.

Red on this branch's base (dfe69cc8): `setAnnotation` places nothing while the close is pending,
because the settled-position translation does not yet follow bytes across a pending close at a
typed closer. That translation is on the PR head (b3f90719, 03264dfe), which this row is meant to
be merged onto; drop this commit if it is merged anywhere without them.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 6ef95955f5 feat(shared-react): paint every annotation by element; decorators draw sub-spans
The CSS Custom Highlight painter and its host-style translator are gone.
A text leaf is always painted whole on its own element — its carrier is
cut into pieces where a hold ends — and a read-only decorator held in
part draws sub-spans of its own rendered text from a per-editor paint
store the index writes after every commit: the React decorators
subscribe to it, and the chapter and read-only glyph decorators render
from it in `createDOM`/`updateDOM` (the chapter moves off the string
decorator portal, as its own comment recommended). Click, hover,
`selected` and `getAnnotationRanges` all go through the same elements.

The display-run scanners, the sync driver, the registry anchors and the
separator sync read a run's value and glyphs through the whole carrier
(`$logicalText`, `$logicalNextSibling`, `$lastPiece`) and write through
`$setLogicalText`, so a cut run is never duplicated or resynced.

Painting tests read elements instead of highlight ranges.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- fe8597f2ec fix(shared): respell and copy a cut unmatched closer as one closer
An unmatched closer an annotation holds in part is several pieces. Its
marker setter wrote the new spelling into the piece in hand and left the
other pieces' bytes beside it (`\x**`); it now joins the pieces first, as
the glyph setters do.

Its HTML export made one `<unmatched>` element per piece, and the import
rebuilds a whole closer from each element's marker, so a copy of a cut
closer pasted two closers. A later piece is now tagged, and pastes as
nothing when the element before it is the same closer's; a copy that
begins inside the closer still pastes the whole closer, as it does for a
closer in one piece. The text of the exported elements stays the bytes of
each piece, so a reader outside the editor sees the closer's bytes once.

`$pieceOffset` gives where a piece's text starts in its carrier, so a
point in a piece can be read against the whole carrier.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- d0a2246b28 test(platform-editor): add the annotation-transparency oracle
Every keystroke case of the pending/settled oracle now also runs with a
fixed set of annotations covering every display byte of the edited
paragraph (each carrier whole, a prefix, a suffix, an interior byte, and
two crossings into the content beside it), at every spelling the cut
carrier gives the site, and every observable — pending and settled
getUsj(), screen and document text, caret reports and round trips, the
collab delta — must equal the plain run's. The annotated run is graded on
its own as well: every annotation paints exactly the bytes it holds plus
whitespace, nothing paints in part, and a removal is reported exactly when
the annotation's bytes are gone.

The pending/settled oracle's case runner is factored out for it, reading
and writing a cut carrier through its whole chain, which is what the plain
run already did on an unsplit node.

The jsdom highlight shims and the docs' highlight painting go with the
highlighter.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 2aa0071a7b fix(platform-editor): export a carrier cut into pieces as the one node it is
A display-byte carrier an annotation holds in part is several sibling
nodes, and `EditorState.toJSON()` writes each piece as its own serialized
node. The exporter emitted one verse per verse piece, one unmatched closer
per piece, and dropped only the caller piece that matched the caller,
spelling the rest of the caller into the note's content. So `getUsj()`,
the saved file and a Markers-view copy all changed when an annotation was
set.

`joinSerializedPieces` (displayPieces.state.ts) is the serialized twin of
`$logicalChildren`/`$logicalText`: each chain written as its head with the
chain's whole text and every hold restated in it. The exporter reads the
tree through it, so every reader below it sees one node per carrier and
the export of a cut carrier is the export of the uncut one, in every view.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 804d0f95ad chore(shared-react): drop an unused import
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- fd4e9b7745 fix(shared): own and grace display runs through a cut carrier's chain
A verse an annotation cuts into pieces is one verse, but every piece is a
VerseNode, so each continuation answered the verse descriptor's owner
predicate and was synced as an owner of runs that sit after the head's
chain. The predicate now answers for the head only.

A loose run value cut into pieces found its owner by the glyph one step
back; from a continuation that step lands on the value's own head, so the
`\va`/`\vp`, `\cat` and `\ca`/`\cp` value arms read the sibling before
the value's whole carrier.

The flank grace for a missing run measured the caret against the anchor
it was handed; the note's caller is handed as its head, so the end of a
cut caller was not graced and a cut inside it was. It now measures
against the anchor's last piece.

A note's caller is recognized by its whole text, so a caller an
annotation cut into pieces stays the caller.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 3d6ee79244 fix(shared-react): send, count and apply a carrier cut into pieces as one node
A verse or unmatched closer an annotation holds in part is cut into pieces
of its own class, and every piece is a VerseNode/ImmutableUnmatchedNode.
The ops stream emitted one embed per piece, OT counting gave each piece a
unit (or its text length), and the apply side removed only the first piece
of a deleted verse, inserted remote nodes between pieces, and changed the
fields of one piece only. A cut para prefix glyph's later pieces leaked
into content ops, a note's cut char opener kept its separator NBSP, and a
retag to the same para marker wrote the glyph's whole text into its first
piece beside the rest.

Now a later piece of an embed carrier is neither an embed nor OT text
(`$isEmbedContinuation`, contribution 0, its position is its embed's); the
adaptor sends each carrier's bytes from its first piece (whole logical
text, a caller's typed bytes gathered from every piece); the apply side
deletes and changes every piece, never inserts before a later piece,
rewrites a para glyph through `$setLogicalText`, iterates a span's glyphs
logically, and appends span content before a cut closer's first piece.

Tests cut every carrier of the editable and standard fixtures and assert
the delta, every OT position (both coordinate systems) and the result of
eleven remote updates are the uncut document's.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 6ee2e3bee6 test(platform-editor): compare the ops stream and the copied bytes in the transparency oracle
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 630df881ce fix(shared): read a cut carrier as one carrier in the content model
A verse or unmatched closer an annotation cuts into pieces is still one
USJ content item, but every piece became an item of its own, so every
content index after it in the block was off by one per extra piece. Its
later pieces now stand for nothing on their own: the item index of any
piece is the carrier's, an element point between two pieces is inside the
carrier and resolves to the point in front of it (the closest point to
its left), and the boundary after the item is after its last piece.

A note caller cut into pieces is read whole: every piece is the caller,
and each answers for its own share of the bytes typed into it, so a piece
holding only caller bytes is not counted as content and typed bytes are
measured against the whole caller.

The book's `\id` glyph, a paragraph glyph's separator site and a char
span's marker retarget read the whole glyph: every piece of the `\id`
glyph is the book prefix, the separator is looked for after the glyph's
last piece and a caret on any piece is on the glyph, and a marker change
compares and respells (or, for a note-content marker, removes) the whole
glyph rather than leaving a cut one spelled with the old marker.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 703940a432 fix(shared): measure a caret in a glyph against the whole glyph
A caret at a cut an annotation made inside a glyph is inside the glyph,
not at an end of it. The glyph-position guards measured against the piece
the point is on, so a caret at a cut was taken for a glyph end: a
collapsed caret there was not moved out (an insert would land between
the glyph's bytes), a range endpoint there was stepped as if it named the
glyph's end, and Enter at a cut inside a canonical char closer read as
"after the span". Each now places the point in the whole glyph's bytes,
and a caret moved out of a glyph goes to the end of its last piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ccff8c08c9 fix(shared): set a cut verse's fields on every piece
A verse an annotation cuts into pieces is one verse, but its number, sid,
alternate and publishing numbers and unknown attributes were written to
the piece in hand only. A reader that meets a later piece first (a walk
back from the text after the verse reaches its last piece) then read the
old fields. Each setter now writes every piece of the verse.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- fa7c323fd7 fix(platform-editor): carry positions through a renamed closer an annotation cuts
A pending opener rename restates every closer position between the
closer's live and settled spellings. The closer was keyed by whichever
piece the rename recorded and restated against that piece's bytes, so a
position on an annotated closer was clamped to one piece's length in both
directions. The closer is now keyed by its first piece and restated
against its whole text.

The end of a note's caller is the front of the note's content; for a
caller an annotation cuts, that end is the end of its last piece, and the
end of its first piece is inside the caller. And the front of a closing
glyph's later piece is inside that glyph, the same as any other interior
byte of it.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- bacdb56cbb fix(shared-react): locate and resolve positions through a cut carrier's whole bytes
The positions layer measured a carrier's display bytes on the piece in
hand: a later piece of a verse, glyph, run value, caller or chapter glyph
reported the wrong span (or none), a location into the carrier resolved
to an offset past the first piece, a run piece's role was looked up by
identity against its head only, the end of a verse's first piece was
taken for the end of the verse, and "beside" a carrier landed between
its pieces.

`$displayBytesOf` now answers every piece with the whole carrier's
bytes, measured on its logical text; a point is restated in the whole
carrier with `$logicalOffset` on the way out and mapped back to the piece
holding it with `$pieceAtLogicalOffset` on the way in (left at a cut,
the side a split keeps a point on). `$runPieceOf`, `$displayByteCarriers`,
`$endsInOwnSeparator`, `$locationOfFirstByteAfter`, `$locationBeside`,
`$pointBeside`, `$bareValueStart` and `$getJsonPathIndexes` read the
chain the same way.

The two helpers are new in `displayPieces.state.ts`, with tests.

The new data-driven test cuts every carrier of the 2SA corpus and asserts
every location resolves and round-trips, and every caret in a carrier
reports, as in the uncut document. It also needs the shared logical
content model to skip a continuation piece (`$getLogicalItemShapes`) and
to recognise a caller's later piece as the caller
(`$shouldIgnoreNodeForContentIndexes`); with those two changes it passes.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 2c16b62d24 fix(shared-react): sync a cut verse's display runs from its first piece
The verse display-run sync ran once per VerseNode transform, so each
later piece of a verse an annotation had cut was synced as an owner of
its own. Its scan anchors on the verse's last piece, so it finds the same
run, but the owner the sync pends, reports and graces is then a piece and
not the verse. The transform now hands the sync the verse's first piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 7f92dd9067 fix(shared-react): find and step over a cut verse as one verse
The verse finders handed back whichever piece of a verse they met: a
backward scan met a cut verse at its last piece, a caret in a later piece
was "in" that piece, the scan for the next verse started inside the
current one, and ArrowUp/ArrowDown selected the start of a verse's
second piece. BCV read the number's position in the piece in hand, so a
caret before the number of a cut verse reported the verse itself.

The finders now name a verse by its first piece, scans start after its
last piece, the verse arrows land past the last piece, and BCV compares
the caret's offset in the whole verse text against the number's.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 67ebc5c74c fix(platform-editor): treat a cut glyph or caller as one node in marker actions
A marker inserted at a caret in a note's caller went after the piece the
caret was in, so with the caller cut by an annotation the new span landed
between its pieces and the caller's later bytes became note content. It
now goes after the whole caller.

Removing a char marker from part of a span folds the glyph beside the
removed content into the removal; with that glyph cut into pieces only
the adjacent piece was folded, and the rest was left alone in a clone
span: a span of glyph bytes and no content. The whole glyph is folded.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c1bb8be14f fix(shared-react): place note carets through a cut caller, glyph or closer as one
The note helpers read a note's display carriers piece by piece: a later
piece of the caller counted as note content (offset 0 landed in the
caller), each glyph piece was its own glyph stop, a glyph or category
offset was clamped to one piece, the closer's index was its last piece's
(the content end and an empty note's typing slot fell between the
closer's pieces), offset 0 of a closer's later piece was taken for the
front of the closer, and a quotation over a cut verse quoted the verse
once per piece.

Each now reads the carrier whole: the caller is matched by its first
piece, a glyph is one stop and an offset into it or into the category
value is mapped to the piece holding it, the closer's index is its first
piece's, and a verse is quoted once.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8dbdcfa988 fix(platform-editor): keep a cut glyph whole when the copy limit shortens a selection
The copy limit keeps glyph text and a chapter line whole or leaves it out,
so a cut never removes half a marker. It tested each text node on its
own, so with a glyph cut into pieces by an annotation it kept the pieces
that fit and dropped the rest: the clipboard got `\c 1` of `\c 12`, or a
verse without its trailing space when a mark joined it. The end now falls
in front of the glyph's first piece, as it falls in front of an uncut
glyph.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- a9ef41ecef fix(platform-editor): navigate to and past a verse marker an annotation cuts
Navigation to a verse looked at the node after the verse marker to decide
where the verse's content starts. With the marker cut into pieces that
node was the marker's own next piece, a verse node, so every such verse
read as empty and the caret was parked at the end of the first piece:
inside `\v N `, where the next keystroke edits the marker. The content now
starts after the marker's last piece, and the caret rests at its end.

A piece cut off a verse marker, or joined back to it, is also no longer
taken for a verse created or destroyed, so setting or removing an
annotation does not make the plugin re-read and re-report the position.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- e50ea7406c fix(shared-react): guard a protected note shell cut into pieces as one shell
The note-shell caret guard listed only the caller's first piece in the
shell and named the closer by its last piece. With either cut by an
annotation, the shell's trailing edge was the end of the caller's first
piece (inside the caller), the content's end was between the closer's
pieces, and a caret in a later piece of the closer was not seen as in
the closer at all.

The shell now holds every piece of the caller, the closer is named by its
first piece and a caret is inside it past its front in any piece, and the
caret host for an empty note is anchored after the last piece of the node
it follows.

`$describeLogicalPoint`/`$describeLogicalSelection` (test utils) spell a
point as the uncut document does, so a cut and an uncut run compare point
for point; the note-utils piece test uses them too.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 1dcf4fdd7e fix(platform-editor): read a cut verse marker as one verse in the active-text box
The empty-verse status scanned a verse's section up to the next verse
node, so a verse marker cut into pieces by an annotation ended its own
section at its next piece: every such verse read as empty, and the
ellipsis placeholder was drawn after its first piece, in the middle of
the verse number. The section is now read over the paragraph's logical
children, and a verse is reported once, by its last piece, which is
where the placeholder belongs. The active verse is named by the same key,
so a caret anywhere in the marker hides it; a click on the placeholder
puts the caret after the whole marker.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- dbacbad118 fix(shared-react): keep and highlight a cut note caller whole
With the opening glyph deleted, the note transform compared the note's
first child to the caller spelling; a caller an annotation cut into
pieces fails that on its first piece alone, so the piece was ejected into
the paragraph as body text. The compare now reads the whole carrier, and
a first child that is salvaged out moves with all its pieces.

The caller highlight put its class on one element, the caller's first
piece; it now marks every piece's element and clears them all.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- f85e20261c fix(shared-react): merge a glyph-less span inside a cut glyph pair
Reuniting a glyph-less span with its glyph-bearing neighbour splices the
content after the neighbour's opener (at index 1) or before its closer
(its last child). With the opener or the closer cut into pieces by an
annotation, that put the content inside the glyph (`\` + "and " + `nd`)
and bytes that re-tokenize as a different span. The content now goes
after the opener's last piece and before the closer's first piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 004ec6ad64 fix(shared-react): treat a cut verse and para glyph whole in structure deletes
The verse beside a caret was the piece beside it, so a verse cut into
pieces by an annotation was armed and deleted by its last piece alone.
Merging a paragraph into the one before dropped only the first piece of
its marker glyph; the rest was moved in as text (`first` + `q1` +
`second`), saved as body bytes.

The adjacent verse is now named by its first piece, an armed verse is
removed with every piece, and the merge drops every piece of the
prefix glyph.

The armed verse delete itself is reached only by a read-only verse
decorator in practice (an editable verse does not arm in jsdom), so it is
covered by reading, not by a test.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 68621c8adf fix(platform-editor): read a cut paragraph glyph as the glyph in the marker menu
A caret in any piece of a paragraph's marker glyph is on the glyph, and
the separator follows the glyph's last piece. The `\` palette took a
caret in a later piece for content and offered character markers, and a
paragraph pick there split the paragraph instead of retagging it.

The retag guard pins that a retag to another marker, which respells the
glyph and so joins its pieces, still puts the caret at the content
start.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c7a00088c6 fix(shared-react): cross an atom cut into pieces in one arrow press
A token-mode text or a read-only construct's glyph is an atom the arrows
cross in one stop. With an annotation cutting it into pieces, each piece
was its own atom: a press landed between the pieces, inside a glyph that
takes no caret, and the next press had to cross the rest. A crossing now
lands past the atom's last piece going forward and before its first
going back, and a caret at a piece's far edge steps over the whole atom.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c319d0202f test(platform-editor): put the element-point caret behind the whole cut glyph
The comment holds the last character of the first marker glyph (the
paragraph's `\p`), so the glyph is cut into two pieces, and the element
boundary right after its first piece is inside the glyph, not behind it.
The test means the caret behind the glyph, which is now after its last
piece: the same document position the test named when the glyph was one
node.

This test still fails here for a reason outside this change: with the
paragraph glyph cut, the marker-edit engine writes a separator between
its pieces and respells its first piece, so the paragraph no longer has
the shape the test sets up.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- e73fa6aeda test(platform-editor): spell the cut paragraph glyph in its fixture comment
The comment escaped the backslashes it names.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 0ffde80f48 feat(shared): read a carrier's pieces by whole-carrier offset; hand a piece's pass to its head
Per-node engine passes (node transforms, caret checks, caret-preserving
rewrites) need to treat a carrier cut into pieces as one node: map a
caret point on any piece to its offset in the whole carrier and back
(`$logicalOffset`, `$pieceAt`, `$pieceStart`), ask whether a node is a
piece of a carrier (`$isPieceOf`), and redirect a continuation piece's
pass to its head (`$deferToPieceHead`), which then reads and writes the
whole carrier.

New helpers only; nothing existing changes.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 33803d1daf fix(platform-editor): settle and edit a carrier cut into pieces as one node
An annotation held over part of a glyph, a verse, an attribute value, a
note's caller or a chapter's glyph text cuts it into pieces. The marker-
edit engine read each piece as if it were the whole carrier, so a held
verse number lost its number and grew a second \va run, a held glyph was
renamed from partial bytes, a held caller was doubled on screen, and no
paragraph, note, chapter or \id line holding a piece could ever reach a
Tier-2 fixed point (its live signature listed every piece; the fresh
rebuild has none), so every settle re-spliced it.

- Per-node passes (the glyph, verse, unmatched, caller, chapter-glyph and
  attribute-value transforms, the Standard-view whitespace exemption, the
  historic re-pend scan) hand a continuation's pass to its head, which
  classifies the whole carrier's text and writes it with
  `$setLogicalText`; caret checks read the caret through the chain, and
  pends and the departure clocks key on the carrier's head.
- The rebuild's signature skips continuations and spells each carrier
  whole; the fragment, run scanners, sentinel runs, sid carry-over, note
  caller slot and `\id` prefix treat a cut carrier as its pieces in one
  slot; the read-only settle mirrors renames and verse collection the
  same way.
- Opener renames, closer splits, the protected-closer guard, note
  unwrapping, the paragraph-glyph separator heal, forward Delete of an
  opener and Enter inside a caller anchor on the chain's ends.
- A destroyed continuation piece pends its run's owner only when the run
  no longer spells what the owner wants: a join that keeps its bytes is
  no deletion.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 3c98a16b03 test(platform-editor): write and read a cut carrier whole in three annotation tests
Each of these tests holds an annotation over part of a carrier, which
cuts the carrier into pieces, and then wrote or read it through its first
node as if that were the whole carrier: re-spelling `|grace` by writing
the first piece (`|`), appending to a chapter glyph through its first
piece (`\c `, which put the typed `\ca 5\ca*` before the held number),
and reading a rewritten run's text from the held piece alone. Typing at
the glyph's end lands in its last piece, and a run's text is the whole
chain, so each now writes or reads the whole carrier; the document and
the assertions are unchanged.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 4fd4d58ac0 test(platform-editor): type the attribute-bearing fixtures of the cut-carrier rebuild tests
`MarkerObject` leaves `lemma` and `who` untyped, so the fixtures declare
them the way the other annotation suites do; the typecheck rejected the
object literals.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8dd88f8013 fix(platform-editor): carry a hold on a cut note caller across a typed-closer close
The note shell fragment compared the caller head piece alone and gave
the caller one span, so a caller an annotation had cut into pieces was
neither recognized nor carried. It now reads the caller whole and spells
every piece, and spells a cut opening glyph once.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 3130da266d test(shared): call the unmatched closer's own exportDOM signature
The node's override takes no editor, so the test's call did not
typecheck.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 1780229856 test(platform-editor): delete every piece of an annotated verse in the undo pin
A hold on the number cuts the verse glyph into pieces; deleting a verse
removes all of them, and only then is the hold destroyed.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 13dcc48ef6 fix(shared): type the serialized piece join and its position tests
The joined node and its restated children were built as object literals
of the base serialized type, which the compiler rejects for the text and
children properties those nodes carry; they are built as the wider types
they are. The renamed-closer position tests build their closer locations
from the closer location the closer reports, so the path has a closer's
type.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 98adb5c87b fix(platform-editor): carry marks into a read-only settle without host callbacks
The scratch editor a read-only settle restores marks into is read once
and discarded, but its own normalization can remove an emptied nested
mark, and that removal fired the host's onRemove with "destroyed" for an
annotation the live document still holds. The marks restored there now
carry no callbacks.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 2a88433655 fix(platform-editor): place the caret past every piece of a retagged paragraph glyph
A same-marker retag keeps a cut glyph's pieces, so the content start is
past the last of them. The tests that retyped a cut verse glyph or a cut
attribute value by a single node's text now write through the whole
carrier; the pending caller-end position test platform2 held back lands
with the caller transform fixed.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 5dc17373e3 chore(platform-editor): drop a duplicate import
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8639de730a fix(shared): give a mark fresh DOM listeners on every element it creates
The listeners were cached on the node object, and the reconciler freezes
every node object it commits, DOM created for it or not. A mark rewritten
in place (addID, a suppressed removal) got a new node object with no
listener and a reused element; once an undo brought that object back
after its element had gone with its removal, createDOM wrote the listener
onto the frozen object and threw mid-reconciliation, leaving the DOM map
short of every element reconciled after it.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 83f851b8ad style: name the Lexical readers with the $ prefix and drop two useless fragments
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ee606be63c fix(platform-editor): read through marks and cut carriers at two whitespace rules; grade transparency by composition
Whitespace typed on the outer side of a glyph goes onto the text beside
it through an annotation mark there, as it does with no mark. A lone NBSP
piece an annotation cut off a note's caller is the caller's, not a display
separator the copy drops.

The transparency oracle compares caret reports as the set of locations per
document position (a cut carrier spells a boundary twice), composes each
run's ops and live delta (a mark over content has always sent one text as
several inserts), skips node-shaped keystrokes on a content node a crossing
mark cuts, and ends its crossings on a byte that is not whitespace.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 9951147e20 fix(platform-editor): report the end of a closing glyph in a settling scope as the closer's end
A caret at the end of a closing glyph (`\w*`@3) reports `closingMarkerOffset: 3` when its
paragraph has nothing pending, but inside a settling paragraph the byte alignment resolved it
with byte addressing, which spells that boundary as the front of the content after the glyph
(`{content[2], offset: 0}`). The two name the same USFM position, yet a host read one spelling
or the other depending only on whether the paragraph was pending.

The annotation-transparency oracle found it: the plan's marks over a literal `\ p` left a lone
`\` text node that the engine pends, so the annotated run had a settle scope where the plain run
had none, and the end of the cut closer's last piece reported the next text's start. The same
happened for an uncut closer in any settling paragraph.

The end of a closing glyph (the end of its last piece) now asks the alignment for the glyph's
last byte and reports one past it, so it answers as the caret does outside a pending scope.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- bd81ea74ea fix(shared-react): diff a single-node edit over its block, as the whole-document diff does
The single-dirty-leaf fast path diffed the node's old text against its new text alone. That is
not the diff the whole-document fallback makes, in two ways:

- An insert carried none of the attributes the node's ancestors give its bytes: a byte typed in a
  `\w` span went out with no `char`, so a host composing the ops held an unattributed byte inside
  the span while the editor's own delta has it in the span. When the same edit took the full diff
  (a commit that dirtied a second leaf) it went out with `char`, so two runs of one keystroke
  composed to different documents.
- Where an edit could sit among equal bytes was decided from the node's own edges. `Delta.diff`'s
  cleanup slides an edit left over a short equality, so an NBSP typed in front of a span's
  separator (`\w~grace`, caret after `\w`) went out as `retain 19` when the separator rode on the
  content text and `retain 20` when a comment starting at `g` had left it a node of its own. Both
  compose to the same text; the whole-document diff sends `retain 20`.

The fast path now diffs the node's block — its ops before and after, behind the last op of the
block before it — and retains to the block's start. An edit inside one text node can slide across
no line end or embed, so the diff weighs exactly what the whole-document diff weighs around it,
and sends the same ops with the same attributes however marks cut the text.

Both differences are content-mark behavior, not specific to display-byte pieces: any mark that cuts
a text node, or any commit that happened to take the other path, changed the ops.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c5f65023bb fix(platform-editor): call the renamed piece helper where a caret lands in a marker name
A type guard in the spacing test keeps the opener narrowing through the
piece-head filter.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ca20a34358 test(platform-editor): compare ops by sorted keys in the transparency oracle
An op's attribute object is built in a different key order on the two
runs when a mark splits a text into several nodes; the ops are the same
change. Compare canonical JSON so only a real difference reads as one.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- e6ceb310a6 test(platform-editor): hold the annotated run to the plain run's invariants, read a cut nameless glyph whole
The transparency oracle compared the annotated run's pending/settled
checks against an empty list, so a case the plain run also fails (and
the pending oracle lists) read as an annotation defect. Transparency
means the annotated run fails exactly the checks the plain run fails,
with the same detail: compare the two result sets instead.

The nameless-paragraph spelling of the screen read the glyph's first
piece, so a glyph an annotation had cut was never spelled with its name.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 962bca9cc9 docs(platform-editor): say what a class lookup misses now that every paint is an element
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 907cbae999 fix(shared): keep a hold on its own byte when an identical byte is typed or dropped beside it
The hold remap aligned a carrier's old and new text prefix-first, so a byte typed in front of an
identical held byte (`\` typed before a verse's held `\`) took the hold, and the engine then moved
the typed byte out as content still carrying the annotation. A rewrite that dropped one of two
identical bytes (`\\v 1 ` -> `\v 1 `) likewise kept the unheld one and lost the held one.

The remap now reads a user's edit at the caret: when the caret is a collapsed point in the carrier
and the texts are a pure insertion ending at it or a deletion starting at it, that edit decides
which bytes moved, so a byte typed at a hold's edge is outside it and a deleted byte is the one the
user deleted. Otherwise the alignment is hold-weighted: among longest alignments it keeps the most
held bytes, then inserts the fewest bytes strictly inside a hold, then takes the earliest match.
`$setLogicalText` re-measures its holds itself without the caret, since an engine rewrite is not
the user's edit and the caret it leaves in the carrier names nothing.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 86f68c0f6c fix(shared-react): report a mark Lexical drops without remove() while display bytes still hold
setAnnotation's onRemove contract has every <mark> report its own removal
when it goes, whatever else still holds the annotation. A mark that a
selection delete empties is dropped by Lexical without its remove()
override running, so it never made that report; and while a display byte
still held the annotation the index's last-holder report stayed quiet too.
A crossing annotation (a mark over content plus a hold on a glyph) lost to
a range edit was then never reported until the glyph went as well.

TypedMarkNode now tells the listening reporter which marks went through
remove() (takeRemovedTypedMarks). The display-annotation index reports, once
and with the mark's own text, an annotation whose mark was dropped without
it when no mark holds the annotation any more — unless a node began holding
the annotation in the same commit, which is a settle handing the mark's
bytes on to a display byte rather than a loss. TSDoc names that hand-on.

The transparency oracle's paint check required "no report while any byte is
held", stricter than the contract. It now accepts one report while display
bytes still hold when a mark held the annotation once it was set and no mark
holds it any more; the never-reported-when-gone and reported-twice checks
stay.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 19d81c06a2 fix(shared): remove a token carrier whole when a deletion takes one of its pieces
A carrier in `token` mode (an atomic note shell glyph or caller, an unmatched closer) is atomic:
Lexical removes or replaces it whole, and the deletion transforms rely on that (a protected note
shell's opener is restored when it is gone). Cut into pieces, each piece is a token of its own, so
a Delete at the front of a held `\f` removed only its `\` piece; the `f` continuation was promoted
to a carrier, the note still had an opener, nothing restored it, and the screen showed `f` where
the saved document said `\f`.

Every piece of a cut token carrier now records the carrier's whole text (a state the piece
transform writes and a join clears). When a piece's record no longer matches its carrier's text, a
piece was taken, and the transform removes every piece that recorded it, so the deletion
transforms see exactly what they see for the unsplit token. Text typed in place of a piece records
nothing and stays. Serialized output drops the record with the rest of the piece bookkeeping.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 27b9d71dce test(platform-editor): announce the copied selection in the popover view's transparency oracle
The oracle's copy observable selected the whole edited paragraph and
dispatched COPY_COMMAND without announcing the selection. In the popover
view the shell's caret guard confines a range touching a protected note to
the note's content when a selection change is announced; unannounced, it
ran only when jsdom's own deferred native selectionchange (a setTimeout)
happened to fire before the copy, which depended on whether React had
work to flush in between. The same case copied the whole paragraph on one
run and the note interior on the next, plain or annotated alike, so the
~400 [copy] keys of the popover view were a race, not a difference the
annotations make.

The copy now announces the selection in the same update in that view, as
the keystroke runner does for every caret placement there, so the guard
always confines it first.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 2f9078ce58 fix(shared): keep a caret between a parent's children in place when a carrier is cut or joined
A caret at a boundary between an element's children is an element point: a child index. Cutting a
carrier into pieces inserts siblings without restoring the selection, so a caret after the carrier
kept its index and ended up between its pieces; joining pieces removed siblings and let Lexical
re-anchor the caret onto a neighbouring text. After a deleted note caller was put back, the caret
placed at the start of the note's content (the boundary before its first span, measured while the
caller was one node) landed inside the caller once the annotation cut it again, so the next
keystroke typed into the caller instead of the content.

The cut and the join now restate every element point on the carrier's parent: a point after the
carrier moves by the number of pieces added or removed, and a point between two joined pieces
becomes the text point in the joined node where the boundary was.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- aa313afe9b fix(platform-editor): keep a marker edit pending through annotations
Two things made an annotated document settle a marker edit at once where the
same document without annotations keeps it pending until the caret departs.

An annotation change edits no byte, but wrapping content in a mark splits its
text node, and cutting a display-byte carrier into pieces inserts siblings.
Both dirty nodes nobody edited, and the engine's per-node passes read that
dirtiness as an edit: text in a `\cp` span beside its chapter pends on any
dirtying, so setting an annotation there left pends behind that the next
keystroke's caret move settled, rebuilding the chapter scope under the user.
The engine's transforms, its destroyed-run listener and its departure clock
now sit out updates tagged ANNOTATION_CHANGE_TAG.

The caret shield that keeps the text being typed pending widened only over
direct plain-text siblings, and the departure trigger compared only the
caret's own node. A mark cuts one run of content text into several nodes,
and Lexical resolves a caret at a mark's end to the start of the text after
it, so a `\` typed at the end of marked text read as departed. The shield
now reads the run through marks ($leafBesideThroughMarks, a new shared
helper), and the trigger uses the same shield the settle applies.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- d92b2d387e test(platform-editor): read a nameless glyph whole in the oracle's screen
`$namelessMarked` decided whether a paragraph glyph is the nameless `\` from
the glyph node's own text. An annotation over the glyph's `\` cuts `\x` into
pieces `\` | `x`, and the head piece alone reads as nameless, so the screen
gained a placeholder name the document never had. Read the logical text.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- cffff8673d feat(shared): read neighbours and text runs through annotation marks
A TypedMarkNode is presentation only, but wrapping content in one moves
its text into the mark and cuts a text at the range's edges, so a rule
that asks for "the node after this one" or "this node's text" by direct
siblings stops at the mark's edge and answers differently from the
unmarked document. Several rules already walk out of and into marks by
hand, each slightly differently.

throughMarks.utils.ts gives them one answer: the node after/before a
node or a child boundary, stepping into a mark that starts there and out
of one that ends there (never out of any other parent), with a carrier
cut into pieces read as one node, and the run of adjacent text a mark
cut one text into.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ccc862c9ff fix(shared-react): read a protected note's neighbours through annotation marks
The note-shell caret guard decided whether a caret was in front of a
protected note, past its closer, or at the end of its row by direct
siblings. Text an annotation mark wraps is a mark's child, so the end of
the text before the note (its next sibling is null; the mark's is the
note) and the start of the text after it were not seen as beside the
note: a caret announced there stayed outside, and what was typed landed
outside the note the host edits, where the unmarked document puts it
inside.

Every "the node beside this point" reading in the guard now goes through
the through-marks helpers, and a point at the end of a carrier's
non-last piece stays inside the carrier.

The test utils' $describeLogicalPoint also spells a point as the
unmarked document does (the nearest non-mark parent, and the whole run
of text a mark cut), so a marked and an unmarked run compare point for
point.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ed0d0fd65b fix(shared-react): keep `|…` after a char opener as content in the ops stream
The ops stream and the delta-doc length drop text that starts with the
legacy NBSP-`|` attribute prefix. A `|` typed right after a char span's
opening glyph (`\bd |bold`) makes the span's first content text start with
that prefix, because its NBSP is the opener's separator, so the whole
content vanished from the ops while getUsj() kept it as content `|bold`.
And the check read one text node, so a mark cutting that text (`<NBSP>|`,
mark `b`, `old`) dropped only the first piece: the ops depended on where an
annotation cut the text.

$isLegacyAttributeText is now the one definition both sides read: the
prefix over the node's logical text, except text right after a char span's
opening glyph, found through marks and pieces by $charSeparatorPrefixLength
— the exception the editor→USJ conversion already makes.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- e055d6996f fix(shared-react): report a caret in a carrier cut into pieces by its place in the whole carrier
A caret in text that spells no display bytes and no content of its own (a verse whose glyph has a
space typed in front of it) reports the place beside it: after it for a point past its front, in
front of it otherwise. That reading measured the caret against the piece it sat in, so the start
of every later piece of a cut verse read as the front of the verse and reported the verse itself,
where the uncut verse reported the text after it.

The reading now takes the caret's offset into the whole carrier and the neighbours of the whole
chain.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 81e9184269 test(platform-editor): compare round-trip failures per document position in the transparency oracle
The round trip is checked per caret position, and a cut inside a carrier adds a caret position at
a document position the plain run has one caret position for. Where the plain run's round trip
fails inside such a carrier (a space typed in front of a verse glyph, an accepted pending-oracle
failure), the annotated run listed the identical failure once per caret position at the cut. The
comparison now lists each distinct failure once, as the position reports are compared per document
position; a caret position that fails differently still shows as its own entry.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 85aa871bdf fix(shared): read a byte typed in the same update at the caret before a rewrite re-measures holds
`$setLogicalText` re-measures a carrier's holds across the rewrite without the caret. When the
user's keystroke and the engine's rewrite land in one update, the stored holds were still measured
against the text before the keystroke, so the caret-free alignment also decided where the typed
byte went, and a byte typed in front of an identical held byte could take the hold. The holds are
now first read against the text as it stands (the keystroke read at the caret), then re-measured
across the rewrite.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8574d67694 fix(platform-editor): read a Standard-view space run across annotation marks
Standard view shows each space of a run of two or more as NBSP while
typing and collapses the run to one space on save. Both rules read one
text node at a time. An annotation mark cuts a text where its range
starts or ends, so a space typed beside a marked space (`\w*` + space +
mark(` o`)) made a run the rules never saw: the screen showed two plain
spaces, which HTML collapses to one, and getUsj() saved both, where the
same keystroke with no mark shows two NBSPs and saves one space.

- The display transform maps the run of text nodes through marks
  ($textRunThroughMarks) and rewrites every node of it that changes; the
  char-separator test reads the span through marks too.
- The exporter collapses a run where it joins two texts: after the
  display->data inversion a plain space is always a display space, so
  the joined text's leading spaces drop when the text before ends in one.
- The logical position model carries the run from one segment into the
  next (collapsedSpaceRunRanges' `continuesRun`), so reported offsets
  stay in the exporter's coordinates.

The node-utils test that pinned a run split across a mark's edge as two
uncollapsed spaces encoded exactly the divergence from the unmarked
document; it now asserts the collapsed measure.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- d783848869 fix(platform-editor): move a space typed after a verse onto it through a mark
Whitespace typed at the start of the text after a verse glyph moves onto
the glyph's separator run, where typing at the glyph's end puts it. The
rule found the glyph as the text's previous sibling, so a text an
annotation mark holds (previous sibling null; the mark's is the verse)
kept the space as content: the same keystroke saved ` b` after the verse
instead of `b`.

The glyph is now found through marks, and its text is read and written
as one carrier ($logicalText / $setLogicalText) and a protected caller's
end found at its last piece, since an annotation can cut either into
pieces.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 08cc7e7c2a test(platform-editor): accept a mark's own removal report while a later holder remains
The oracle's removal check allowed a report while bytes are still held
only when no <mark> held the annotation any more. In the popover view a
range edit can destroy the content mark (which reports, as the onRemove
contract says each mark does) while the pending edit leaves a carrier's
byte as content text that a NEW mark then holds: the annotation is held by
a mark again, but the mark that reported is gone, and the report is the
contract's. The check now records the marks holding each annotation once
it is set and accepts one report while bytes remain when any of those
marks has gone.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 9916217998 style(platform-editor): keep the engine-pass guard apart from the run-sync doc
The guard's definition sat between `$syncAndPendOwner`'s doc comment and the
function after it; move it beside the settle-delay constant so each doc
comment stays above the code it describes.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ab41390ff3 fix(shared-react): type over a protected note's mark-split range as over the unmarked one
Lexical re-reads a range's end into an annotation mark that starts or
ends there, so typing over a range of a protected note's content whose
start or end sits at a mark's edge (`\ft ~|Hello\ft*` with `Hello`
marked) reaches RangeSelection.insertText with its two ends in
different inline parents. insertText then treats the range as reaching
across elements: it climbs to the paragraph and moves the paragraph's
text after the note into the note's content (`\ft x the\f*`), which the
popover then saves.

When such a range is confined to the note, the insertion command now
removes it first (RangeSelection.removeText walks the range node by
node and climbs nothing) and the insertion types at the caret, as with
no mark there. $isRangeSplitByMarks (throughMarks.utils.ts) names the
shape: the ends' parents differ only because a mark holds one of them.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 29b27d6559 test(platform-editor): a cut `\ca` glyph pends nothing and settles as uncut
Cutting a `\ca` span's glyph into pieces through setAnnotation used to pend
the chapter scope (the piece cut dirtied the span's content text, which pends
on any dirtying beside its chapter), so the settled-position plan translated
every later annotation range one top-level item too far. Pin that the cut
pends nothing and that a value typed into the span reads the same pending
document cut or uncut.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8f35324631 test(platform-editor): an annotation over a pending literal leaves it pending
This row pinned that setting an annotation over a typed `\q1` literal settled
the paragraph inside the annotation's own update. That settle is the
non-transparency aa313afe removes: an annotation is no edit and no caret
move, so the same document without the annotation keeps the literal pending
until the caret departs, and the annotated one must too. The row's contract
— getUsj() returns the settled paragraphs, not the literal — is unchanged
and still asserted; only the pend-count expectation flips to "still pending".

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- a6fee17dc1 fix(shared): let holds choose only where one edit slides over identical bytes
The hold-weighted alignment chose among every longest alignment, so a hold whose bytes were
deleted could be matched onto identical bytes elsewhere in the carrier: removing a verse's
alternate number `12a` matched the hold on it to the verse number `12`, the annotation survived
on bytes it never held, and its removal was never reported.

Holds now decide only where the change is one inserted or removed run that could sit at more than
one offset over identical bytes (`\\v` -> `\v` by deleting either `\`). There they keep the most
held bytes, then avoid inserting inside a hold, then fall back to the common-prefix placement.
Every other change is aligned by bytes alone, as before, so a hold never moves onto bytes it did
not hold.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8d2ccfda1c test(platform-editor): drain native selectionchange between oracle keystroke steps
jsdom fires selectionchange from a zero-delay timer queued when the DOM
selection is written, and Lexical writes it in the commit microtask after
the update that moved the selection. Lexical answers the event by reading
the DOM selection back (normalizing the caret at a boundary, e.g. a caret
at a closer's start to the end of the text before it) and announcing
SELECTION_CHANGE_COMMAND, which the note shell's caret guard acts on.
Whether that answer landed before or after the runner's next step depended
on timer order, so the same popover case was reachable on one run and not
the next, and two identical runs of the transparency oracle disagreed by
hundreds of keys.

runKeystrokeCase, depart and the transparency oracle's annotation setup and
copy now wait for every queued selectionchange (drainNativeSelectionChange):
rounds of two timer turns, so a commit still pending in a microtask has
written the DOM selection and queued its event before the round ends,
repeated until a round delivers none. A browser always delivers the event
before the next key, so this is the order a user gets.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 79d496d4dd test(platform-editor): read a space run across a mark's edge as one run
Two tests encoded a space run that an annotation mark cuts into two text
nodes as two runs, which is exactly how the marked document diverged
from the unmarked one; they now expect the run read as one.

- The logical-model oracle's independent reference conversion collapsed
  each text node on its own, as the exporter did. It now continues a
  run from the text before, as the exporter does where it joins the two,
  so the oracle still checks the model against the exporter.
- exactAnnotationLocations: once `\w grace\w*` is deleted, the marked
  `In the ` runs into ` of God`; a run of two shows as NBSPs, as the
  unmarked `In the  of God` does, so the mark's text (and the text its
  removal reports) is `In the~`.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 6e681126ce test(platform-editor): type the paragraph the verse leading-space test reads
getFirstChildOrThrow() returns a LexicalNode, which has no
getAllTextNodes(); the test now names the paragraph an ElementNode, so
the package typechecks.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- b5be1508a3 fix(platform-editor): remove a mark-split range before typing or pasting over it
Lexical reads a range end at an annotation mark's edge into the mark,
and its RangeSelection.insertText over a range whose ends then sit in
different inline parents climbs to the paragraph and moves the
paragraph's text after the range's end into the start's parent. Typing
or pasting over `grac` in `\w grace\w* of God`, with `gra` marked, saved
`\w x of Gode\w*`: the paragraph's following text pulled into the span.
The same happens inside a protected note's content.

$prepareReplaceSelection (the replacement preparation every typed
insertion passes through) already removes a glyph-touching range before
the stock insertion; it now also removes a glyph-free range whose ends
differ only by marks, via the new $removeRangeSplitByMarks
(throughMarks.utils.ts), so the insertion types at a caret. The
Standard-view paste claim, which inserts with insertText itself, routes
through the same helper before inserting.

The note-shell guard's own LOW-priority removal is dropped: the
preparation covers the protected note too (the platform test fails for
the note without it and passes with it), so the guard keeps only its
confinement, and its marks test drops the command-path case the
platform test now covers.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c459cc130f fix(shared-react): read an empty verse's content and a verse's predecessor through annotation marks
The trailing-space transform removes the space that is an empty verse's whole content, but it read
the verse before the text and the node after it directly, and returned early for any text inside a
mark, so a space an annotation held after an empty verse stayed and the editor's USJ kept a byte
core's round trip drops. The check now reads the previous and next node and the parent through
marks (`throughMarks.utils.ts`, whose `$parentOutsideMarks` is exported for it) and applies the
same spacing exemptions to that unmarked view. A tree with no mark at the text reads exactly as
before.

The verse transform reads its predecessor through `$previousSiblingThroughMarks` instead of its own
descent into marks, and a continuation piece of a cut verse leaves the decision to its first piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 33be7b9aea refactor(shared): read neighbours through marks with the through-marks helpers
The separator sync, the note caller lookup, the run-anchor owner check and the position readers
each carried their own walk into and out of annotation marks. They now read the node before or
after, the parent outside marks, and the node at a boundary through `throughMarks.utils.ts`, and
iterate a span's glyphs once per carrier (`$logicalChildren`). The only reads that differ are at an
empty mark, which the helpers step over as the unmarked tree has nothing there.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 96f527ee9c refactor(platform-editor): read the caret shield's run through the shared marks helper
The caret shield walked its plain-text run with $leafBesideThroughMarks, a
helper added beside the shared throughMarks layer that does the same job as
its $textRunThroughMarks. Route the shield through $textRunThroughMarks and
drop the duplicate (and its test; throughMarks.utils is tested on its own).

Claude-Session: h…
tjcouch-sil added a commit that referenced this pull request Nov 5, 2026
Every annotation paints as CSS classes on a real element, with the class names
a `<mark>` carries (`editor-typed-mark-<type>`, `editor-typed-markOverlap-<type>`,
`annotationId-<id>`, plus `display-annotation` on display bytes); the CSS Custom
Highlight painter and its host-style translator are gone.

- Content text an annotation holds stays in a `TypedMarkNode`; display bytes
  stay held as `displayAnnotationsState` on their carrier.
- An editable display-byte carrier (marker glyph, verse, attribute run, note
  caller, chapter glyph, unmatched closer) is cut into pieces at every hold
  boundary (`displayPieces.state.ts`), each held whole or not at all, and every
  structural reader goes through the piece and through-marks helpers.
- Read-only decorators draw class-carrying sub-spans (`decoratorPaint.ts`).
- Each annotation's first and last painted element get the `typedMarkStart` /
  `typedMarkEnd` edge classes, so a host can draw one box over many elements.
- Settles, copy/cut/paste, collab ops, positions and undo behave exactly as
  with no annotation; the annotation-transparency oracle runs every keystroke
  case plain vs annotated in four views and compares every observable.

This commit squashes PR #26's branch onto main after #11 was squash-merged
(2026-11-04). The branch's own commits follow, oldest first (merges of #11 and
main, and committed-dist rebuilds, left out); the merge fixes against main are
the commits after this one.

==========================================================================
--- 337d2544a6 feat(shared): cut a display-byte carrier into pieces where a hold ends
A carrier an annotation holds in part cannot be painted as one element:
Lexical maps one DOM text node to one TextNode. So the carrier is split at
every hold boundary into pieces of its own class, each held whole or not
at all, and joined again where neighbours are held by the same
annotations. The second and later pieces carry a continuation state; the
chain is read through `$pieces`, `$logicalText`, `$logicalNextSibling`,
`$logicalChildren`, and written through `$setLogicalText`, so a glyph's
canonical check and its setters see one glyph however it is cut.

The piece transform runs beside the hold-basis transform for every
editable carrier class; an unmatched closer, which the basis transform
did not cover, is covered too.

The two basis tests that rewrote a held carrier's text are restated to
write through the whole carrier, because a carrier cut at a hold's edge
is several nodes: the assertion is the same (the hold follows its bytes
through the rewrite), read per piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- bcfc87408b fix(platform-editor): carry annotations across the \id-line settle and a note closed at a typed closer
Both settles re-tokenize their scope and splice fresh nodes in, but neither carried annotations
across the splice, so a comment or check result over the `\id` line's content, a hold on a glyph
there, or anything in a note closed by typing its own closer (its content, its caller, its opening
glyph, and the text the close moves out after the note) was silently dropped. A mark directly in
the line or the note also told the host it had been destroyed.

Both now go through the same byte carry every other settle scope uses
(`$captureMarkByteRanges` / `$restoreMarkByteRanges`), on the mutating and the read-only side, so
`getUsj()` while the edit is pending shows the marks where the settle leaves them:

- `$rebuildBook` captures over the line's content plus any paragraphs it is widened over, and
  restores over the line's content followed by the blocks it starts (`$bookRegionFragment`).
  `$settledBookLine` does the same through `$carryMarksIntoSerializedBookLine`, which materializes
  the line and its following blocks together under a scratch book.
- `$closeNoteAtOwnCloser` rebuilds the whole note, shell included, so it captures over the note's
  shell bytes plus its content (`$withNoteShellFragment`) and restores over the closed note's
  shell, content and closing glyph followed by what the close moved out
  (`$closedNoteRegionFragment`). A collapsed note's caller decorator is spelled as its caller.
  `$settledNoteScope`'s close path carries through `$carryMarksIntoSerialized` with a new
  `"closedNote"` kind.

The scratch-editor half of `$carryMarksIntoSerialized` is factored into
`$carryCapturedThroughScratch` so the book-line variant shares it rather than repeating it. Marks
removed directly with the old line or note children are flagged the way `$rebuildNoteContent`
already flags them, so the host is not told they were destroyed.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 66336da757 test(platform-editor): pin an annotation set on moved-out text while a note close is pending
A host that annotates the text a typed closer moves out of its note, while that close is still
pending, names it where the settled document has it: in the paragraph after the note. The row pins
that the annotation lands on those bytes while pending and stays on them once the close settles,
in Standard view, with expanded notes, and in Unformatted view.

Red on this branch's base (dfe69cc8): `setAnnotation` places nothing while the close is pending,
because the settled-position translation does not yet follow bytes across a pending close at a
typed closer. That translation is on the PR head (b3f90719, 03264dfe), which this row is meant to
be merged onto; drop this commit if it is merged anywhere without them.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 6ef95955f5 feat(shared-react): paint every annotation by element; decorators draw sub-spans
The CSS Custom Highlight painter and its host-style translator are gone.
A text leaf is always painted whole on its own element — its carrier is
cut into pieces where a hold ends — and a read-only decorator held in
part draws sub-spans of its own rendered text from a per-editor paint
store the index writes after every commit: the React decorators
subscribe to it, and the chapter and read-only glyph decorators render
from it in `createDOM`/`updateDOM` (the chapter moves off the string
decorator portal, as its own comment recommended). Click, hover,
`selected` and `getAnnotationRanges` all go through the same elements.

The display-run scanners, the sync driver, the registry anchors and the
separator sync read a run's value and glyphs through the whole carrier
(`$logicalText`, `$logicalNextSibling`, `$lastPiece`) and write through
`$setLogicalText`, so a cut run is never duplicated or resynced.

Painting tests read elements instead of highlight ranges.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- fe8597f2ec fix(shared): respell and copy a cut unmatched closer as one closer
An unmatched closer an annotation holds in part is several pieces. Its
marker setter wrote the new spelling into the piece in hand and left the
other pieces' bytes beside it (`\x**`); it now joins the pieces first, as
the glyph setters do.

Its HTML export made one `<unmatched>` element per piece, and the import
rebuilds a whole closer from each element's marker, so a copy of a cut
closer pasted two closers. A later piece is now tagged, and pastes as
nothing when the element before it is the same closer's; a copy that
begins inside the closer still pastes the whole closer, as it does for a
closer in one piece. The text of the exported elements stays the bytes of
each piece, so a reader outside the editor sees the closer's bytes once.

`$pieceOffset` gives where a piece's text starts in its carrier, so a
point in a piece can be read against the whole carrier.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- d0a2246b28 test(platform-editor): add the annotation-transparency oracle
Every keystroke case of the pending/settled oracle now also runs with a
fixed set of annotations covering every display byte of the edited
paragraph (each carrier whole, a prefix, a suffix, an interior byte, and
two crossings into the content beside it), at every spelling the cut
carrier gives the site, and every observable — pending and settled
getUsj(), screen and document text, caret reports and round trips, the
collab delta — must equal the plain run's. The annotated run is graded on
its own as well: every annotation paints exactly the bytes it holds plus
whitespace, nothing paints in part, and a removal is reported exactly when
the annotation's bytes are gone.

The pending/settled oracle's case runner is factored out for it, reading
and writing a cut carrier through its whole chain, which is what the plain
run already did on an unsplit node.

The jsdom highlight shims and the docs' highlight painting go with the
highlighter.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 2aa0071a7b fix(platform-editor): export a carrier cut into pieces as the one node it is
A display-byte carrier an annotation holds in part is several sibling
nodes, and `EditorState.toJSON()` writes each piece as its own serialized
node. The exporter emitted one verse per verse piece, one unmatched closer
per piece, and dropped only the caller piece that matched the caller,
spelling the rest of the caller into the note's content. So `getUsj()`,
the saved file and a Markers-view copy all changed when an annotation was
set.

`joinSerializedPieces` (displayPieces.state.ts) is the serialized twin of
`$logicalChildren`/`$logicalText`: each chain written as its head with the
chain's whole text and every hold restated in it. The exporter reads the
tree through it, so every reader below it sees one node per carrier and
the export of a cut carrier is the export of the uncut one, in every view.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 804d0f95ad chore(shared-react): drop an unused import
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- fd4e9b7745 fix(shared): own and grace display runs through a cut carrier's chain
A verse an annotation cuts into pieces is one verse, but every piece is a
VerseNode, so each continuation answered the verse descriptor's owner
predicate and was synced as an owner of runs that sit after the head's
chain. The predicate now answers for the head only.

A loose run value cut into pieces found its owner by the glyph one step
back; from a continuation that step lands on the value's own head, so the
`\va`/`\vp`, `\cat` and `\ca`/`\cp` value arms read the sibling before
the value's whole carrier.

The flank grace for a missing run measured the caret against the anchor
it was handed; the note's caller is handed as its head, so the end of a
cut caller was not graced and a cut inside it was. It now measures
against the anchor's last piece.

A note's caller is recognized by its whole text, so a caller an
annotation cut into pieces stays the caller.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 3d6ee79244 fix(shared-react): send, count and apply a carrier cut into pieces as one node
A verse or unmatched closer an annotation holds in part is cut into pieces
of its own class, and every piece is a VerseNode/ImmutableUnmatchedNode.
The ops stream emitted one embed per piece, OT counting gave each piece a
unit (or its text length), and the apply side removed only the first piece
of a deleted verse, inserted remote nodes between pieces, and changed the
fields of one piece only. A cut para prefix glyph's later pieces leaked
into content ops, a note's cut char opener kept its separator NBSP, and a
retag to the same para marker wrote the glyph's whole text into its first
piece beside the rest.

Now a later piece of an embed carrier is neither an embed nor OT text
(`$isEmbedContinuation`, contribution 0, its position is its embed's); the
adaptor sends each carrier's bytes from its first piece (whole logical
text, a caller's typed bytes gathered from every piece); the apply side
deletes and changes every piece, never inserts before a later piece,
rewrites a para glyph through `$setLogicalText`, iterates a span's glyphs
logically, and appends span content before a cut closer's first piece.

Tests cut every carrier of the editable and standard fixtures and assert
the delta, every OT position (both coordinate systems) and the result of
eleven remote updates are the uncut document's.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 6ee2e3bee6 test(platform-editor): compare the ops stream and the copied bytes in the transparency oracle
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 630df881ce fix(shared): read a cut carrier as one carrier in the content model
A verse or unmatched closer an annotation cuts into pieces is still one
USJ content item, but every piece became an item of its own, so every
content index after it in the block was off by one per extra piece. Its
later pieces now stand for nothing on their own: the item index of any
piece is the carrier's, an element point between two pieces is inside the
carrier and resolves to the point in front of it (the closest point to
its left), and the boundary after the item is after its last piece.

A note caller cut into pieces is read whole: every piece is the caller,
and each answers for its own share of the bytes typed into it, so a piece
holding only caller bytes is not counted as content and typed bytes are
measured against the whole caller.

The book's `\id` glyph, a paragraph glyph's separator site and a char
span's marker retarget read the whole glyph: every piece of the `\id`
glyph is the book prefix, the separator is looked for after the glyph's
last piece and a caret on any piece is on the glyph, and a marker change
compares and respells (or, for a note-content marker, removes) the whole
glyph rather than leaving a cut one spelled with the old marker.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 703940a432 fix(shared): measure a caret in a glyph against the whole glyph
A caret at a cut an annotation made inside a glyph is inside the glyph,
not at an end of it. The glyph-position guards measured against the piece
the point is on, so a caret at a cut was taken for a glyph end: a
collapsed caret there was not moved out (an insert would land between
the glyph's bytes), a range endpoint there was stepped as if it named the
glyph's end, and Enter at a cut inside a canonical char closer read as
"after the span". Each now places the point in the whole glyph's bytes,
and a caret moved out of a glyph goes to the end of its last piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ccff8c08c9 fix(shared): set a cut verse's fields on every piece
A verse an annotation cuts into pieces is one verse, but its number, sid,
alternate and publishing numbers and unknown attributes were written to
the piece in hand only. A reader that meets a later piece first (a walk
back from the text after the verse reaches its last piece) then read the
old fields. Each setter now writes every piece of the verse.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- fa7c323fd7 fix(platform-editor): carry positions through a renamed closer an annotation cuts
A pending opener rename restates every closer position between the
closer's live and settled spellings. The closer was keyed by whichever
piece the rename recorded and restated against that piece's bytes, so a
position on an annotated closer was clamped to one piece's length in both
directions. The closer is now keyed by its first piece and restated
against its whole text.

The end of a note's caller is the front of the note's content; for a
caller an annotation cuts, that end is the end of its last piece, and the
end of its first piece is inside the caller. And the front of a closing
glyph's later piece is inside that glyph, the same as any other interior
byte of it.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- bacdb56cbb fix(shared-react): locate and resolve positions through a cut carrier's whole bytes
The positions layer measured a carrier's display bytes on the piece in
hand: a later piece of a verse, glyph, run value, caller or chapter glyph
reported the wrong span (or none), a location into the carrier resolved
to an offset past the first piece, a run piece's role was looked up by
identity against its head only, the end of a verse's first piece was
taken for the end of the verse, and "beside" a carrier landed between
its pieces.

`$displayBytesOf` now answers every piece with the whole carrier's
bytes, measured on its logical text; a point is restated in the whole
carrier with `$logicalOffset` on the way out and mapped back to the piece
holding it with `$pieceAtLogicalOffset` on the way in (left at a cut,
the side a split keeps a point on). `$runPieceOf`, `$displayByteCarriers`,
`$endsInOwnSeparator`, `$locationOfFirstByteAfter`, `$locationBeside`,
`$pointBeside`, `$bareValueStart` and `$getJsonPathIndexes` read the
chain the same way.

The two helpers are new in `displayPieces.state.ts`, with tests.

The new data-driven test cuts every carrier of the 2SA corpus and asserts
every location resolves and round-trips, and every caret in a carrier
reports, as in the uncut document. It also needs the shared logical
content model to skip a continuation piece (`$getLogicalItemShapes`) and
to recognise a caller's later piece as the caller
(`$shouldIgnoreNodeForContentIndexes`); with those two changes it passes.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 2c16b62d24 fix(shared-react): sync a cut verse's display runs from its first piece
The verse display-run sync ran once per VerseNode transform, so each
later piece of a verse an annotation had cut was synced as an owner of
its own. Its scan anchors on the verse's last piece, so it finds the same
run, but the owner the sync pends, reports and graces is then a piece and
not the verse. The transform now hands the sync the verse's first piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 7f92dd9067 fix(shared-react): find and step over a cut verse as one verse
The verse finders handed back whichever piece of a verse they met: a
backward scan met a cut verse at its last piece, a caret in a later piece
was "in" that piece, the scan for the next verse started inside the
current one, and ArrowUp/ArrowDown selected the start of a verse's
second piece. BCV read the number's position in the piece in hand, so a
caret before the number of a cut verse reported the verse itself.

The finders now name a verse by its first piece, scans start after its
last piece, the verse arrows land past the last piece, and BCV compares
the caret's offset in the whole verse text against the number's.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 67ebc5c74c fix(platform-editor): treat a cut glyph or caller as one node in marker actions
A marker inserted at a caret in a note's caller went after the piece the
caret was in, so with the caller cut by an annotation the new span landed
between its pieces and the caller's later bytes became note content. It
now goes after the whole caller.

Removing a char marker from part of a span folds the glyph beside the
removed content into the removal; with that glyph cut into pieces only
the adjacent piece was folded, and the rest was left alone in a clone
span: a span of glyph bytes and no content. The whole glyph is folded.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c1bb8be14f fix(shared-react): place note carets through a cut caller, glyph or closer as one
The note helpers read a note's display carriers piece by piece: a later
piece of the caller counted as note content (offset 0 landed in the
caller), each glyph piece was its own glyph stop, a glyph or category
offset was clamped to one piece, the closer's index was its last piece's
(the content end and an empty note's typing slot fell between the
closer's pieces), offset 0 of a closer's later piece was taken for the
front of the closer, and a quotation over a cut verse quoted the verse
once per piece.

Each now reads the carrier whole: the caller is matched by its first
piece, a glyph is one stop and an offset into it or into the category
value is mapped to the piece holding it, the closer's index is its first
piece's, and a verse is quoted once.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8dbdcfa988 fix(platform-editor): keep a cut glyph whole when the copy limit shortens a selection
The copy limit keeps glyph text and a chapter line whole or leaves it out,
so a cut never removes half a marker. It tested each text node on its
own, so with a glyph cut into pieces by an annotation it kept the pieces
that fit and dropped the rest: the clipboard got `\c 1` of `\c 12`, or a
verse without its trailing space when a mark joined it. The end now falls
in front of the glyph's first piece, as it falls in front of an uncut
glyph.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- a9ef41ecef fix(platform-editor): navigate to and past a verse marker an annotation cuts
Navigation to a verse looked at the node after the verse marker to decide
where the verse's content starts. With the marker cut into pieces that
node was the marker's own next piece, a verse node, so every such verse
read as empty and the caret was parked at the end of the first piece:
inside `\v N `, where the next keystroke edits the marker. The content now
starts after the marker's last piece, and the caret rests at its end.

A piece cut off a verse marker, or joined back to it, is also no longer
taken for a verse created or destroyed, so setting or removing an
annotation does not make the plugin re-read and re-report the position.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- e50ea7406c fix(shared-react): guard a protected note shell cut into pieces as one shell
The note-shell caret guard listed only the caller's first piece in the
shell and named the closer by its last piece. With either cut by an
annotation, the shell's trailing edge was the end of the caller's first
piece (inside the caller), the content's end was between the closer's
pieces, and a caret in a later piece of the closer was not seen as in
the closer at all.

The shell now holds every piece of the caller, the closer is named by its
first piece and a caret is inside it past its front in any piece, and the
caret host for an empty note is anchored after the last piece of the node
it follows.

`$describeLogicalPoint`/`$describeLogicalSelection` (test utils) spell a
point as the uncut document does, so a cut and an uncut run compare point
for point; the note-utils piece test uses them too.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 1dcf4fdd7e fix(platform-editor): read a cut verse marker as one verse in the active-text box
The empty-verse status scanned a verse's section up to the next verse
node, so a verse marker cut into pieces by an annotation ended its own
section at its next piece: every such verse read as empty, and the
ellipsis placeholder was drawn after its first piece, in the middle of
the verse number. The section is now read over the paragraph's logical
children, and a verse is reported once, by its last piece, which is
where the placeholder belongs. The active verse is named by the same key,
so a caret anywhere in the marker hides it; a click on the placeholder
puts the caret after the whole marker.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- dbacbad118 fix(shared-react): keep and highlight a cut note caller whole
With the opening glyph deleted, the note transform compared the note's
first child to the caller spelling; a caller an annotation cut into
pieces fails that on its first piece alone, so the piece was ejected into
the paragraph as body text. The compare now reads the whole carrier, and
a first child that is salvaged out moves with all its pieces.

The caller highlight put its class on one element, the caller's first
piece; it now marks every piece's element and clears them all.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- f85e20261c fix(shared-react): merge a glyph-less span inside a cut glyph pair
Reuniting a glyph-less span with its glyph-bearing neighbour splices the
content after the neighbour's opener (at index 1) or before its closer
(its last child). With the opener or the closer cut into pieces by an
annotation, that put the content inside the glyph (`\` + "and " + `nd`)
and bytes that re-tokenize as a different span. The content now goes
after the opener's last piece and before the closer's first piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 004ec6ad64 fix(shared-react): treat a cut verse and para glyph whole in structure deletes
The verse beside a caret was the piece beside it, so a verse cut into
pieces by an annotation was armed and deleted by its last piece alone.
Merging a paragraph into the one before dropped only the first piece of
its marker glyph; the rest was moved in as text (`first` + `q1` +
`second`), saved as body bytes.

The adjacent verse is now named by its first piece, an armed verse is
removed with every piece, and the merge drops every piece of the
prefix glyph.

The armed verse delete itself is reached only by a read-only verse
decorator in practice (an editable verse does not arm in jsdom), so it is
covered by reading, not by a test.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 68621c8adf fix(platform-editor): read a cut paragraph glyph as the glyph in the marker menu
A caret in any piece of a paragraph's marker glyph is on the glyph, and
the separator follows the glyph's last piece. The `\` palette took a
caret in a later piece for content and offered character markers, and a
paragraph pick there split the paragraph instead of retagging it.

The retag guard pins that a retag to another marker, which respells the
glyph and so joins its pieces, still puts the caret at the content
start.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c7a00088c6 fix(shared-react): cross an atom cut into pieces in one arrow press
A token-mode text or a read-only construct's glyph is an atom the arrows
cross in one stop. With an annotation cutting it into pieces, each piece
was its own atom: a press landed between the pieces, inside a glyph that
takes no caret, and the next press had to cross the rest. A crossing now
lands past the atom's last piece going forward and before its first
going back, and a caret at a piece's far edge steps over the whole atom.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c319d0202f test(platform-editor): put the element-point caret behind the whole cut glyph
The comment holds the last character of the first marker glyph (the
paragraph's `\p`), so the glyph is cut into two pieces, and the element
boundary right after its first piece is inside the glyph, not behind it.
The test means the caret behind the glyph, which is now after its last
piece: the same document position the test named when the glyph was one
node.

This test still fails here for a reason outside this change: with the
paragraph glyph cut, the marker-edit engine writes a separator between
its pieces and respells its first piece, so the paragraph no longer has
the shape the test sets up.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- e73fa6aeda test(platform-editor): spell the cut paragraph glyph in its fixture comment
The comment escaped the backslashes it names.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 0ffde80f48 feat(shared): read a carrier's pieces by whole-carrier offset; hand a piece's pass to its head
Per-node engine passes (node transforms, caret checks, caret-preserving
rewrites) need to treat a carrier cut into pieces as one node: map a
caret point on any piece to its offset in the whole carrier and back
(`$logicalOffset`, `$pieceAt`, `$pieceStart`), ask whether a node is a
piece of a carrier (`$isPieceOf`), and redirect a continuation piece's
pass to its head (`$deferToPieceHead`), which then reads and writes the
whole carrier.

New helpers only; nothing existing changes.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 33803d1daf fix(platform-editor): settle and edit a carrier cut into pieces as one node
An annotation held over part of a glyph, a verse, an attribute value, a
note's caller or a chapter's glyph text cuts it into pieces. The marker-
edit engine read each piece as if it were the whole carrier, so a held
verse number lost its number and grew a second \va run, a held glyph was
renamed from partial bytes, a held caller was doubled on screen, and no
paragraph, note, chapter or \id line holding a piece could ever reach a
Tier-2 fixed point (its live signature listed every piece; the fresh
rebuild has none), so every settle re-spliced it.

- Per-node passes (the glyph, verse, unmatched, caller, chapter-glyph and
  attribute-value transforms, the Standard-view whitespace exemption, the
  historic re-pend scan) hand a continuation's pass to its head, which
  classifies the whole carrier's text and writes it with
  `$setLogicalText`; caret checks read the caret through the chain, and
  pends and the departure clocks key on the carrier's head.
- The rebuild's signature skips continuations and spells each carrier
  whole; the fragment, run scanners, sentinel runs, sid carry-over, note
  caller slot and `\id` prefix treat a cut carrier as its pieces in one
  slot; the read-only settle mirrors renames and verse collection the
  same way.
- Opener renames, closer splits, the protected-closer guard, note
  unwrapping, the paragraph-glyph separator heal, forward Delete of an
  opener and Enter inside a caller anchor on the chain's ends.
- A destroyed continuation piece pends its run's owner only when the run
  no longer spells what the owner wants: a join that keeps its bytes is
  no deletion.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 3c98a16b03 test(platform-editor): write and read a cut carrier whole in three annotation tests
Each of these tests holds an annotation over part of a carrier, which
cuts the carrier into pieces, and then wrote or read it through its first
node as if that were the whole carrier: re-spelling `|grace` by writing
the first piece (`|`), appending to a chapter glyph through its first
piece (`\c `, which put the typed `\ca 5\ca*` before the held number),
and reading a rewritten run's text from the held piece alone. Typing at
the glyph's end lands in its last piece, and a run's text is the whole
chain, so each now writes or reads the whole carrier; the document and
the assertions are unchanged.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 4fd4d58ac0 test(platform-editor): type the attribute-bearing fixtures of the cut-carrier rebuild tests
`MarkerObject` leaves `lemma` and `who` untyped, so the fixtures declare
them the way the other annotation suites do; the typecheck rejected the
object literals.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8dd88f8013 fix(platform-editor): carry a hold on a cut note caller across a typed-closer close
The note shell fragment compared the caller head piece alone and gave
the caller one span, so a caller an annotation had cut into pieces was
neither recognized nor carried. It now reads the caller whole and spells
every piece, and spells a cut opening glyph once.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 3130da266d test(shared): call the unmatched closer's own exportDOM signature
The node's override takes no editor, so the test's call did not
typecheck.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 1780229856 test(platform-editor): delete every piece of an annotated verse in the undo pin
A hold on the number cuts the verse glyph into pieces; deleting a verse
removes all of them, and only then is the hold destroyed.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 13dcc48ef6 fix(shared): type the serialized piece join and its position tests
The joined node and its restated children were built as object literals
of the base serialized type, which the compiler rejects for the text and
children properties those nodes carry; they are built as the wider types
they are. The renamed-closer position tests build their closer locations
from the closer location the closer reports, so the path has a closer's
type.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 98adb5c87b fix(platform-editor): carry marks into a read-only settle without host callbacks
The scratch editor a read-only settle restores marks into is read once
and discarded, but its own normalization can remove an emptied nested
mark, and that removal fired the host's onRemove with "destroyed" for an
annotation the live document still holds. The marks restored there now
carry no callbacks.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 2a88433655 fix(platform-editor): place the caret past every piece of a retagged paragraph glyph
A same-marker retag keeps a cut glyph's pieces, so the content start is
past the last of them. The tests that retyped a cut verse glyph or a cut
attribute value by a single node's text now write through the whole
carrier; the pending caller-end position test platform2 held back lands
with the caller transform fixed.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 5dc17373e3 chore(platform-editor): drop a duplicate import
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8639de730a fix(shared): give a mark fresh DOM listeners on every element it creates
The listeners were cached on the node object, and the reconciler freezes
every node object it commits, DOM created for it or not. A mark rewritten
in place (addID, a suppressed removal) got a new node object with no
listener and a reused element; once an undo brought that object back
after its element had gone with its removal, createDOM wrote the listener
onto the frozen object and threw mid-reconciliation, leaving the DOM map
short of every element reconciled after it.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 83f851b8ad style: name the Lexical readers with the $ prefix and drop two useless fragments
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ee606be63c fix(platform-editor): read through marks and cut carriers at two whitespace rules; grade transparency by composition
Whitespace typed on the outer side of a glyph goes onto the text beside
it through an annotation mark there, as it does with no mark. A lone NBSP
piece an annotation cut off a note's caller is the caller's, not a display
separator the copy drops.

The transparency oracle compares caret reports as the set of locations per
document position (a cut carrier spells a boundary twice), composes each
run's ops and live delta (a mark over content has always sent one text as
several inserts), skips node-shaped keystrokes on a content node a crossing
mark cuts, and ends its crossings on a byte that is not whitespace.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 9951147e20 fix(platform-editor): report the end of a closing glyph in a settling scope as the closer's end
A caret at the end of a closing glyph (`\w*`@3) reports `closingMarkerOffset: 3` when its
paragraph has nothing pending, but inside a settling paragraph the byte alignment resolved it
with byte addressing, which spells that boundary as the front of the content after the glyph
(`{content[2], offset: 0}`). The two name the same USFM position, yet a host read one spelling
or the other depending only on whether the paragraph was pending.

The annotation-transparency oracle found it: the plan's marks over a literal `\ p` left a lone
`\` text node that the engine pends, so the annotated run had a settle scope where the plain run
had none, and the end of the cut closer's last piece reported the next text's start. The same
happened for an uncut closer in any settling paragraph.

The end of a closing glyph (the end of its last piece) now asks the alignment for the glyph's
last byte and reports one past it, so it answers as the caret does outside a pending scope.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- bd81ea74ea fix(shared-react): diff a single-node edit over its block, as the whole-document diff does
The single-dirty-leaf fast path diffed the node's old text against its new text alone. That is
not the diff the whole-document fallback makes, in two ways:

- An insert carried none of the attributes the node's ancestors give its bytes: a byte typed in a
  `\w` span went out with no `char`, so a host composing the ops held an unattributed byte inside
  the span while the editor's own delta has it in the span. When the same edit took the full diff
  (a commit that dirtied a second leaf) it went out with `char`, so two runs of one keystroke
  composed to different documents.
- Where an edit could sit among equal bytes was decided from the node's own edges. `Delta.diff`'s
  cleanup slides an edit left over a short equality, so an NBSP typed in front of a span's
  separator (`\w~grace`, caret after `\w`) went out as `retain 19` when the separator rode on the
  content text and `retain 20` when a comment starting at `g` had left it a node of its own. Both
  compose to the same text; the whole-document diff sends `retain 20`.

The fast path now diffs the node's block — its ops before and after, behind the last op of the
block before it — and retains to the block's start. An edit inside one text node can slide across
no line end or embed, so the diff weighs exactly what the whole-document diff weighs around it,
and sends the same ops with the same attributes however marks cut the text.

Both differences are content-mark behavior, not specific to display-byte pieces: any mark that cuts
a text node, or any commit that happened to take the other path, changed the ops.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c5f65023bb fix(platform-editor): call the renamed piece helper where a caret lands in a marker name
A type guard in the spacing test keeps the opener narrowing through the
piece-head filter.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ca20a34358 test(platform-editor): compare ops by sorted keys in the transparency oracle
An op's attribute object is built in a different key order on the two
runs when a mark splits a text into several nodes; the ops are the same
change. Compare canonical JSON so only a real difference reads as one.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- e6ceb310a6 test(platform-editor): hold the annotated run to the plain run's invariants, read a cut nameless glyph whole
The transparency oracle compared the annotated run's pending/settled
checks against an empty list, so a case the plain run also fails (and
the pending oracle lists) read as an annotation defect. Transparency
means the annotated run fails exactly the checks the plain run fails,
with the same detail: compare the two result sets instead.

The nameless-paragraph spelling of the screen read the glyph's first
piece, so a glyph an annotation had cut was never spelled with its name.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 962bca9cc9 docs(platform-editor): say what a class lookup misses now that every paint is an element
Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 907cbae999 fix(shared): keep a hold on its own byte when an identical byte is typed or dropped beside it
The hold remap aligned a carrier's old and new text prefix-first, so a byte typed in front of an
identical held byte (`\` typed before a verse's held `\`) took the hold, and the engine then moved
the typed byte out as content still carrying the annotation. A rewrite that dropped one of two
identical bytes (`\\v 1 ` -> `\v 1 `) likewise kept the unheld one and lost the held one.

The remap now reads a user's edit at the caret: when the caret is a collapsed point in the carrier
and the texts are a pure insertion ending at it or a deletion starting at it, that edit decides
which bytes moved, so a byte typed at a hold's edge is outside it and a deleted byte is the one the
user deleted. Otherwise the alignment is hold-weighted: among longest alignments it keeps the most
held bytes, then inserts the fewest bytes strictly inside a hold, then takes the earliest match.
`$setLogicalText` re-measures its holds itself without the caret, since an engine rewrite is not
the user's edit and the caret it leaves in the carrier names nothing.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 86f68c0f6c fix(shared-react): report a mark Lexical drops without remove() while display bytes still hold
setAnnotation's onRemove contract has every <mark> report its own removal
when it goes, whatever else still holds the annotation. A mark that a
selection delete empties is dropped by Lexical without its remove()
override running, so it never made that report; and while a display byte
still held the annotation the index's last-holder report stayed quiet too.
A crossing annotation (a mark over content plus a hold on a glyph) lost to
a range edit was then never reported until the glyph went as well.

TypedMarkNode now tells the listening reporter which marks went through
remove() (takeRemovedTypedMarks). The display-annotation index reports, once
and with the mark's own text, an annotation whose mark was dropped without
it when no mark holds the annotation any more — unless a node began holding
the annotation in the same commit, which is a settle handing the mark's
bytes on to a display byte rather than a loss. TSDoc names that hand-on.

The transparency oracle's paint check required "no report while any byte is
held", stricter than the contract. It now accepts one report while display
bytes still hold when a mark held the annotation once it was set and no mark
holds it any more; the never-reported-when-gone and reported-twice checks
stay.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 19d81c06a2 fix(shared): remove a token carrier whole when a deletion takes one of its pieces
A carrier in `token` mode (an atomic note shell glyph or caller, an unmatched closer) is atomic:
Lexical removes or replaces it whole, and the deletion transforms rely on that (a protected note
shell's opener is restored when it is gone). Cut into pieces, each piece is a token of its own, so
a Delete at the front of a held `\f` removed only its `\` piece; the `f` continuation was promoted
to a carrier, the note still had an opener, nothing restored it, and the screen showed `f` where
the saved document said `\f`.

Every piece of a cut token carrier now records the carrier's whole text (a state the piece
transform writes and a join clears). When a piece's record no longer matches its carrier's text, a
piece was taken, and the transform removes every piece that recorded it, so the deletion
transforms see exactly what they see for the unsplit token. Text typed in place of a piece records
nothing and stays. Serialized output drops the record with the rest of the piece bookkeeping.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 27b9d71dce test(platform-editor): announce the copied selection in the popover view's transparency oracle
The oracle's copy observable selected the whole edited paragraph and
dispatched COPY_COMMAND without announcing the selection. In the popover
view the shell's caret guard confines a range touching a protected note to
the note's content when a selection change is announced; unannounced, it
ran only when jsdom's own deferred native selectionchange (a setTimeout)
happened to fire before the copy, which depended on whether React had
work to flush in between. The same case copied the whole paragraph on one
run and the note interior on the next, plain or annotated alike, so the
~400 [copy] keys of the popover view were a race, not a difference the
annotations make.

The copy now announces the selection in the same update in that view, as
the keystroke runner does for every caret placement there, so the guard
always confines it first.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 2f9078ce58 fix(shared): keep a caret between a parent's children in place when a carrier is cut or joined
A caret at a boundary between an element's children is an element point: a child index. Cutting a
carrier into pieces inserts siblings without restoring the selection, so a caret after the carrier
kept its index and ended up between its pieces; joining pieces removed siblings and let Lexical
re-anchor the caret onto a neighbouring text. After a deleted note caller was put back, the caret
placed at the start of the note's content (the boundary before its first span, measured while the
caller was one node) landed inside the caller once the annotation cut it again, so the next
keystroke typed into the caller instead of the content.

The cut and the join now restate every element point on the carrier's parent: a point after the
carrier moves by the number of pieces added or removed, and a point between two joined pieces
becomes the text point in the joined node where the boundary was.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- aa313afe9b fix(platform-editor): keep a marker edit pending through annotations
Two things made an annotated document settle a marker edit at once where the
same document without annotations keeps it pending until the caret departs.

An annotation change edits no byte, but wrapping content in a mark splits its
text node, and cutting a display-byte carrier into pieces inserts siblings.
Both dirty nodes nobody edited, and the engine's per-node passes read that
dirtiness as an edit: text in a `\cp` span beside its chapter pends on any
dirtying, so setting an annotation there left pends behind that the next
keystroke's caret move settled, rebuilding the chapter scope under the user.
The engine's transforms, its destroyed-run listener and its departure clock
now sit out updates tagged ANNOTATION_CHANGE_TAG.

The caret shield that keeps the text being typed pending widened only over
direct plain-text siblings, and the departure trigger compared only the
caret's own node. A mark cuts one run of content text into several nodes,
and Lexical resolves a caret at a mark's end to the start of the text after
it, so a `\` typed at the end of marked text read as departed. The shield
now reads the run through marks ($leafBesideThroughMarks, a new shared
helper), and the trigger uses the same shield the settle applies.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- d92b2d387e test(platform-editor): read a nameless glyph whole in the oracle's screen
`$namelessMarked` decided whether a paragraph glyph is the nameless `\` from
the glyph node's own text. An annotation over the glyph's `\` cuts `\x` into
pieces `\` | `x`, and the head piece alone reads as nameless, so the screen
gained a placeholder name the document never had. Read the logical text.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- cffff8673d feat(shared): read neighbours and text runs through annotation marks
A TypedMarkNode is presentation only, but wrapping content in one moves
its text into the mark and cuts a text at the range's edges, so a rule
that asks for "the node after this one" or "this node's text" by direct
siblings stops at the mark's edge and answers differently from the
unmarked document. Several rules already walk out of and into marks by
hand, each slightly differently.

throughMarks.utils.ts gives them one answer: the node after/before a
node or a child boundary, stepping into a mark that starts there and out
of one that ends there (never out of any other parent), with a carrier
cut into pieces read as one node, and the run of adjacent text a mark
cut one text into.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ccc862c9ff fix(shared-react): read a protected note's neighbours through annotation marks
The note-shell caret guard decided whether a caret was in front of a
protected note, past its closer, or at the end of its row by direct
siblings. Text an annotation mark wraps is a mark's child, so the end of
the text before the note (its next sibling is null; the mark's is the
note) and the start of the text after it were not seen as beside the
note: a caret announced there stayed outside, and what was typed landed
outside the note the host edits, where the unmarked document puts it
inside.

Every "the node beside this point" reading in the guard now goes through
the through-marks helpers, and a point at the end of a carrier's
non-last piece stays inside the carrier.

The test utils' $describeLogicalPoint also spells a point as the
unmarked document does (the nearest non-mark parent, and the whole run
of text a mark cut), so a marked and an unmarked run compare point for
point.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ed0d0fd65b fix(shared-react): keep `|…` after a char opener as content in the ops stream
The ops stream and the delta-doc length drop text that starts with the
legacy NBSP-`|` attribute prefix. A `|` typed right after a char span's
opening glyph (`\bd |bold`) makes the span's first content text start with
that prefix, because its NBSP is the opener's separator, so the whole
content vanished from the ops while getUsj() kept it as content `|bold`.
And the check read one text node, so a mark cutting that text (`<NBSP>|`,
mark `b`, `old`) dropped only the first piece: the ops depended on where an
annotation cut the text.

$isLegacyAttributeText is now the one definition both sides read: the
prefix over the node's logical text, except text right after a char span's
opening glyph, found through marks and pieces by $charSeparatorPrefixLength
— the exception the editor→USJ conversion already makes.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- e055d6996f fix(shared-react): report a caret in a carrier cut into pieces by its place in the whole carrier
A caret in text that spells no display bytes and no content of its own (a verse whose glyph has a
space typed in front of it) reports the place beside it: after it for a point past its front, in
front of it otherwise. That reading measured the caret against the piece it sat in, so the start
of every later piece of a cut verse read as the front of the verse and reported the verse itself,
where the uncut verse reported the text after it.

The reading now takes the caret's offset into the whole carrier and the neighbours of the whole
chain.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 81e9184269 test(platform-editor): compare round-trip failures per document position in the transparency oracle
The round trip is checked per caret position, and a cut inside a carrier adds a caret position at
a document position the plain run has one caret position for. Where the plain run's round trip
fails inside such a carrier (a space typed in front of a verse glyph, an accepted pending-oracle
failure), the annotated run listed the identical failure once per caret position at the cut. The
comparison now lists each distinct failure once, as the position reports are compared per document
position; a caret position that fails differently still shows as its own entry.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 85aa871bdf fix(shared): read a byte typed in the same update at the caret before a rewrite re-measures holds
`$setLogicalText` re-measures a carrier's holds across the rewrite without the caret. When the
user's keystroke and the engine's rewrite land in one update, the stored holds were still measured
against the text before the keystroke, so the caret-free alignment also decided where the typed
byte went, and a byte typed in front of an identical held byte could take the hold. The holds are
now first read against the text as it stands (the keystroke read at the caret), then re-measured
across the rewrite.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8574d67694 fix(platform-editor): read a Standard-view space run across annotation marks
Standard view shows each space of a run of two or more as NBSP while
typing and collapses the run to one space on save. Both rules read one
text node at a time. An annotation mark cuts a text where its range
starts or ends, so a space typed beside a marked space (`\w*` + space +
mark(` o`)) made a run the rules never saw: the screen showed two plain
spaces, which HTML collapses to one, and getUsj() saved both, where the
same keystroke with no mark shows two NBSPs and saves one space.

- The display transform maps the run of text nodes through marks
  ($textRunThroughMarks) and rewrites every node of it that changes; the
  char-separator test reads the span through marks too.
- The exporter collapses a run where it joins two texts: after the
  display->data inversion a plain space is always a display space, so
  the joined text's leading spaces drop when the text before ends in one.
- The logical position model carries the run from one segment into the
  next (collapsedSpaceRunRanges' `continuesRun`), so reported offsets
  stay in the exporter's coordinates.

The node-utils test that pinned a run split across a mark's edge as two
uncollapsed spaces encoded exactly the divergence from the unmarked
document; it now asserts the collapsed measure.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- d783848869 fix(platform-editor): move a space typed after a verse onto it through a mark
Whitespace typed at the start of the text after a verse glyph moves onto
the glyph's separator run, where typing at the glyph's end puts it. The
rule found the glyph as the text's previous sibling, so a text an
annotation mark holds (previous sibling null; the mark's is the verse)
kept the space as content: the same keystroke saved ` b` after the verse
instead of `b`.

The glyph is now found through marks, and its text is read and written
as one carrier ($logicalText / $setLogicalText) and a protected caller's
end found at its last piece, since an annotation can cut either into
pieces.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 08cc7e7c2a test(platform-editor): accept a mark's own removal report while a later holder remains
The oracle's removal check allowed a report while bytes are still held
only when no <mark> held the annotation any more. In the popover view a
range edit can destroy the content mark (which reports, as the onRemove
contract says each mark does) while the pending edit leaves a carrier's
byte as content text that a NEW mark then holds: the annotation is held by
a mark again, but the mark that reported is gone, and the report is the
contract's. The check now records the marks holding each annotation once
it is set and accepts one report while bytes remain when any of those
marks has gone.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 9916217998 style(platform-editor): keep the engine-pass guard apart from the run-sync doc
The guard's definition sat between `$syncAndPendOwner`'s doc comment and the
function after it; move it beside the settle-delay constant so each doc
comment stays above the code it describes.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- ab41390ff3 fix(shared-react): type over a protected note's mark-split range as over the unmarked one
Lexical re-reads a range's end into an annotation mark that starts or
ends there, so typing over a range of a protected note's content whose
start or end sits at a mark's edge (`\ft ~|Hello\ft*` with `Hello`
marked) reaches RangeSelection.insertText with its two ends in
different inline parents. insertText then treats the range as reaching
across elements: it climbs to the paragraph and moves the paragraph's
text after the note into the note's content (`\ft x the\f*`), which the
popover then saves.

When such a range is confined to the note, the insertion command now
removes it first (RangeSelection.removeText walks the range node by
node and climbs nothing) and the insertion types at the caret, as with
no mark there. $isRangeSplitByMarks (throughMarks.utils.ts) names the
shape: the ends' parents differ only because a mark holds one of them.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 29b27d6559 test(platform-editor): a cut `\ca` glyph pends nothing and settles as uncut
Cutting a `\ca` span's glyph into pieces through setAnnotation used to pend
the chapter scope (the piece cut dirtied the span's content text, which pends
on any dirtying beside its chapter), so the settled-position plan translated
every later annotation range one top-level item too far. Pin that the cut
pends nothing and that a value typed into the span reads the same pending
document cut or uncut.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8f35324631 test(platform-editor): an annotation over a pending literal leaves it pending
This row pinned that setting an annotation over a typed `\q1` literal settled
the paragraph inside the annotation's own update. That settle is the
non-transparency aa313afe removes: an annotation is no edit and no caret
move, so the same document without the annotation keeps the literal pending
until the caret departs, and the annotated one must too. The row's contract
— getUsj() returns the settled paragraphs, not the literal — is unchanged
and still asserted; only the pend-count expectation flips to "still pending".

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- a6fee17dc1 fix(shared): let holds choose only where one edit slides over identical bytes
The hold-weighted alignment chose among every longest alignment, so a hold whose bytes were
deleted could be matched onto identical bytes elsewhere in the carrier: removing a verse's
alternate number `12a` matched the hold on it to the verse number `12`, the annotation survived
on bytes it never held, and its removal was never reported.

Holds now decide only where the change is one inserted or removed run that could sit at more than
one offset over identical bytes (`\\v` -> `\v` by deleting either `\`). There they keep the most
held bytes, then avoid inserting inside a hold, then fall back to the common-prefix placement.
Every other change is aligned by bytes alone, as before, so a hold never moves onto bytes it did
not hold.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 8d2ccfda1c test(platform-editor): drain native selectionchange between oracle keystroke steps
jsdom fires selectionchange from a zero-delay timer queued when the DOM
selection is written, and Lexical writes it in the commit microtask after
the update that moved the selection. Lexical answers the event by reading
the DOM selection back (normalizing the caret at a boundary, e.g. a caret
at a closer's start to the end of the text before it) and announcing
SELECTION_CHANGE_COMMAND, which the note shell's caret guard acts on.
Whether that answer landed before or after the runner's next step depended
on timer order, so the same popover case was reachable on one run and not
the next, and two identical runs of the transparency oracle disagreed by
hundreds of keys.

runKeystrokeCase, depart and the transparency oracle's annotation setup and
copy now wait for every queued selectionchange (drainNativeSelectionChange):
rounds of two timer turns, so a commit still pending in a microtask has
written the DOM selection and queued its event before the round ends,
repeated until a round delivers none. A browser always delivers the event
before the next key, so this is the order a user gets.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 79d496d4dd test(platform-editor): read a space run across a mark's edge as one run
Two tests encoded a space run that an annotation mark cuts into two text
nodes as two runs, which is exactly how the marked document diverged
from the unmarked one; they now expect the run read as one.

- The logical-model oracle's independent reference conversion collapsed
  each text node on its own, as the exporter did. It now continues a
  run from the text before, as the exporter does where it joins the two,
  so the oracle still checks the model against the exporter.
- exactAnnotationLocations: once `\w grace\w*` is deleted, the marked
  `In the ` runs into ` of God`; a run of two shows as NBSPs, as the
  unmarked `In the  of God` does, so the mark's text (and the text its
  removal reports) is `In the~`.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 6e681126ce test(platform-editor): type the paragraph the verse leading-space test reads
getFirstChildOrThrow() returns a LexicalNode, which has no
getAllTextNodes(); the test now names the paragraph an ElementNode, so
the package typechecks.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- b5be1508a3 fix(platform-editor): remove a mark-split range before typing or pasting over it
Lexical reads a range end at an annotation mark's edge into the mark,
and its RangeSelection.insertText over a range whose ends then sit in
different inline parents climbs to the paragraph and moves the
paragraph's text after the range's end into the start's parent. Typing
or pasting over `grac` in `\w grace\w* of God`, with `gra` marked, saved
`\w x of Gode\w*`: the paragraph's following text pulled into the span.
The same happens inside a protected note's content.

$prepareReplaceSelection (the replacement preparation every typed
insertion passes through) already removes a glyph-touching range before
the stock insertion; it now also removes a glyph-free range whose ends
differ only by marks, via the new $removeRangeSplitByMarks
(throughMarks.utils.ts), so the insertion types at a caret. The
Standard-view paste claim, which inserts with insertText itself, routes
through the same helper before inserting.

The note-shell guard's own LOW-priority removal is dropped: the
preparation covers the protected note too (the platform test fails for
the note without it and passes with it), so the guard keeps only its
confinement, and its marks test drops the command-path case the
platform test now covers.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- c459cc130f fix(shared-react): read an empty verse's content and a verse's predecessor through annotation marks
The trailing-space transform removes the space that is an empty verse's whole content, but it read
the verse before the text and the node after it directly, and returned early for any text inside a
mark, so a space an annotation held after an empty verse stayed and the editor's USJ kept a byte
core's round trip drops. The check now reads the previous and next node and the parent through
marks (`throughMarks.utils.ts`, whose `$parentOutsideMarks` is exported for it) and applies the
same spacing exemptions to that unmarked view. A tree with no mark at the text reads exactly as
before.

The verse transform reads its predecessor through `$previousSiblingThroughMarks` instead of its own
descent into marks, and a continuation piece of a cut verse leaves the decision to its first piece.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 33be7b9aea refactor(shared): read neighbours through marks with the through-marks helpers
The separator sync, the note caller lookup, the run-anchor owner check and the position readers
each carried their own walk into and out of annotation marks. They now read the node before or
after, the parent outside marks, and the node at a boundary through `throughMarks.utils.ts`, and
iterate a span's glyphs once per carrier (`$logicalChildren`). The only reads that differ are at an
empty mark, which the helpers step over as the unmarked tree has nothing there.

Claude-Session: https://claude.ai/code/session_01KvcPjD9t3ojhfixqiz43us

--- 96f527ee9c refactor(platform-editor): read the caret shield's run through the shared marks helper
The caret shield walked its plain-text run with $leafBesideThroughMarks, a
helper added beside the shared throughMarks layer that does the same job as
its $textRunThroughMarks. Route the shield through $textRunThroughMarks and
drop the duplicate (and its test; throughMarks.utils is tested on its own).

Claude-Session: h…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants