Skip to content

PT-4370: Paint every annotation as classes on elements - #26

Draft
tjcouch-sil wants to merge 20 commits into
mainfrom
pt-4370-unified-annotations
Draft

tjcouch-sil wants to merge 20 commits into
mainfrom
pt-4370-unified-annotations

Conversation

@tjcouch-sil

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

Copy link
Copy Markdown
Member

What and why

TJ's ask for PT-4370: every annotation held and painted the same way, with full CSS fidelity, exact everywhere.

Before this PR an annotation over content text painted as a <mark> with host CSS classes, while an annotation over display bytes (marker glyphs, verse numbers, note callers, attribute runs, chapter glyphs) painted either as classes on the whole carrier element or — when it held only part of the carrier — with a CSS Custom Highlight whose styles were translated from the host's class rules. The highlight path lost every host style a highlight cannot carry (borders, padding, border-radius, cursor, :hover, the find-result ::selection override), and needed a style translator, probes and a stale-range observer to work at all.

Now 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 deleted.

Approach

  • Holding is unchanged: content text in a TypedMarkNode; display bytes as displayAnnotationsState on the carrier (exact offsets, remapped through the character alignment when the carrier's text changes).
  • Pieces (libs/shared/src/nodes/features/displayPieces.state.ts): an editable display-byte carrier is never held in part at the end of an update. A carrier whose holds have a boundary inside its text is cut at every hold boundary into adjacent sibling pieces of its own class (later pieces carry displayPieceContinuationState; every piece of a cut carrier carries a displayPieceChainState id), each held whole or not at all; neighbours with the same hold set are joined back; an orphaned continuation is promoted. $normalizeCarrierPieces runs beside the basis transform for TextNode, MarkerNode, VerseNode and ImmutableUnmatchedNode.
  • Readers go through chain helpers ($logicalText, $pieces, $pieceHead, $lastPiece, $logicalNextSibling, $logicalPreviousSibling, $logicalChildren, $setLogicalText, $pieceAtLogicalOffset, $logicalPointOffset, …) and mark-transparent helpers (throughMarks.utils.ts). Every structural reader that found a run's pieces by position, read a carrier's text whole, or acted once per carrier was routed through them (~260 sites: the display-run scanners and sync, the registry anchors, separators, nested glyphs, the Tier-1/Tier-2 marker-edit engines, settles, the exporter, the collab delta adaptors, copy/paste, the positions layer).
  • Painting (displayAnnotations.index.ts): every text leaf is painted whole on its own element (paintElement); a leaf that would be painted in part is a reported defect, painted whole as the fail-safe. Read-only decorators (ImmutableVerseNode, ImmutableNoteCallerNode, ImmutableChapterNode, ImmutableTypedTextNode) draw their own sub-spans carrying the same classes plus data-annotation-keys, driven by a per-editor paint store (decoratorPaint.ts); ImmutableChapterNode moved from a string decorate() to createDOM rendering. Click/hover, selected, and getAnnotationRanges(type, id) (now DOM ranges over mark elements, painted pieces and decorator sub-spans, in document order) read the same elements.
  • Settles: the book-scope (\id-line) settle and a note closed at a typed closer carry annotations through $captureMarkByteRanges/$restoreMarkByteRanges like the paragraph settle, on both the mutating and the read-only (getUsj() while pending) side.
  • Paste inside a cut carrier does what it does in the uncut carrier: the clipboard-nodes handler splits the carrier at the caret with splitText itself before Lexical's insertion ($splitCarrierAt), the piece transform joins a carrier with a neighbouring text Lexical would merge it with but for piece/hold state ($joinMergeableNeighbours), and a structure-protected paste now inserts through the same command. The transparency oracle copies and pastes a byte at every site inside every carrier, plain vs annotated.
  • Edges: each annotation's first painted element, by document order, gets <theme.typedMarkStart>-<type> (editor-typed-markStart-…) and its last <theme.typedMarkEnd>-<type>, per annotation, across marks, pieces and decorator sub-spans; an element a collapsed note hides is skipped, and edges move when the note is toggled. A host draws one box over an annotation that spans several elements by putting border-block on the base class and the inline side and corners on these (README, Annotation Styles). Recomputed after each commit for annotations whose marks mutated, whose painting changed or whose note toggled; a mark's classes follow it when Lexical re-creates its element.
  • Undo: a host's setAnnotation/removeAnnotation adds no undo step (it merges into the current one), so a document undoes and redoes identically with and without annotations; a comment added or deleted in Marginal's comment panel is the user's own edit and stays its own undo step.
  • Public API unchanged apart from the additive edge classes and their typedMarkStart/typedMarkEnd theme names: setAnnotation, removeAnnotation, getAnnotationRanges, onRemove semantics, click/hover callbacks, CommentPlugin selected. getUsj() never contains annotation marks except the existing comment zmsc milestones.

The transparency oracle

packages/platform/src/editor/annotationLocations/annotationTransparency.* runs every keystroke case of the pending/settled oracle twice per view — on the document as loaded, and after a fixed annotation set is applied through the public setAnnotation API over every display-byte carrier of the edited paragraph (whole, a proper prefix, a proper suffix, an interior byte, and crossings into and out of the content beside it; decorators whole and crossed). A site a cut turns into a boundary runs at both spellings. Every observable must be identical: pending and settled getUsj(), document and screen text, every caret position's report and round trip, the full collab delta, the composed onUsjChange ops stream, and the text/plain of a copy. Before any case runs, every planned annotation must hold exactly its planned bytes. The annotated run is also graded on its own: every held byte painted, nothing non-whitespace painted unheld, no leaf painted in part, and removal reported exactly when an annotation's bytes are gone. Every site×keystroke is accounted as compared or skipped-with-reason. Expected-failure lists per view (standard, standard+expandedNotes, unformatted, standard+protectedNoteShell) are empty.

Rebased onto main (2026-11-05)

#11 was squash-merged, so this branch was squashed onto main (863a2067) as one commit, 3c6dafb8, whose message carries every commit message of this PR's own history (#11's commits, merges and dist rebuilds left out). Three commits on top fix what main's 15 PRs since #11 changed under this work, each with a failing test first:

  • 66610180: main's new chapter-line insertion (chapterLine.utils.ts) reads a \c N glyph an annotation cuts through its pieces (insert after the last piece; a caret in any piece is not past the number; a protected paste at the line's end is allowed); an empty char span's zero-length op is left out of the block-window delta, which main's caret-host/stand-in fix relies on.
  • dd188b5f: a char span's own-child signature skips a caret host, as main's signer does; this PR's carrier split at a paste runs only when no handler claims the insertion, so main's inline-range paste still sees the range; the pending/settled oracle installs its Selection.modify stand-in lazily, so main's typing-fidelity mock is no longer shadowed (it broke main's "Delete before a collapsed note" test); one cut-verse caret test now moves the caret away first, since PT-4370: Settled positions across the public position API #11's final round restores the exact caret a host read.
  • a591c927: a range a mark holds an end of is removed (caret put back at the mark's edge) before main's prose paste types over it.

Gates on 0bc7185d: shared 1069, shared-react 2460 (+1 skipped), platform 5544 (57 expected failures, 142 skipped), library views (SE_TEST_LIBRARY_VIEWS=1) 7257, 2SA 3/3, clipboard families standard 14/14, expandedNotes 14/14, unformatted 17/17, protectedNoteShell 4/4, utilities + scribe 10, lint 0 errors, typecheck 0, committed dist verified current.

After a /code-review high pass over both PRs (2026-11-05)

Each with a failing test first:

  • e1e53562: an annotation's background now paints a verse number in Standard view. The vendored .formatted-font.marker-editable .verse { background-color: transparent } outranked every annotation class rule, so Find's current result or a comment hover across \v 2 had a hole at the number (since this PR also moved partial verse holds onto the element). The grey badge is now given only to the views whose verse is a read-only number, rather than overridden in Standard view.
  • 143f169f: Enter in front of a space an annotation holds no longer leaves the new paragraph starting with it in the hidden-marker views (the trim read only the paragraph's first child, the mark).
  • e2f8287e: a character marker applied over annotated text makes the span it makes with no annotation. The wrap restarted its span at every mark (\nd over "the Lord God" with a comment on "Lord" saved two spans, and in Standard view some shapes saved no span at all). A mark a selection end cuts is split there; a mark the selection holds whole, edges included, moves into the new span like a whole note; a mark that opens the span gets the opener's separator in front of it. Pinned for five annotation positions × both editable views × both apply paths.

Gates on 28288294: platform 5564 (57 expected failures, 142 skipped), shared-react 2462 (+1 skipped), lint 0 errors, typecheck 0, committed dist verified current.

Rebased again onto main 8392866e (2026-11-05, "Remove the scribe package"): this PR's change to scribe's editor-usj.adaptor.ts (keeping a user's lone no-break space at a mark's edge on save) and its test went with the package; nothing else referred to scribe. Head ac6b99e2: typecheck 0, lint 0 errors, shared 1069, shared-react 2462 (+1 skipped), committed dist rebuilt and verified current. The platform suite was not rerun for this rebase (no platform code changed; 5564 passed on the previous head).

After a second /code-review high pass over both PRs (2026-11-06)

17 findings; each was checked by a read-only investigation (code reading plus a repro attempt) before acting. Fixed, with a failing test first where behaviour changes:

  • df961e21: e1e53562's verse-badge rule (.formatted-font:not(.marker-editable) .verse, specificity 0,3,0) outranked the gutter view's .psc-gutter-markers span.verse { background: none }, so every verse number in the paragraph-structure view drew the grey badge again. :where(:not(.marker-editable)) keeps Standard view out at the old specificity (core's E2E asserts the rendered background).
  • f55536a5: the through-marks helpers stepped with getIndexWithinParent()/getChildAtIndex(), O(index) in Lexical, so the whitespace transform's run building was quadratic over a paragraph (4000 texts: 418 ms); they step by sibling links now (2–3 ms). A 6000-text guard test (bound 300 ms; 1266 ms before).
  • 21d3ea26: $tornSecondHalfOf stopped at a whole same-marker span between a torn span's halves while $firstHalfOfSplitSpan walks past it, so a span inserted whole into an attributed span saved the attribute twice (screen ≠ saved). Reached only through a raw insertNodes; paste and insert-marker were tried and are fine.
  • 4e3cf5bb: both pairings pair only spans that settle in the span's own scope; a paragraph span could pair with a span inside a footnote (screen ≠ saved, from a constructed state).
  • f2d08a7f: an annotated copy whose uncut serialization throws falls back to the editor's own copy instead of aborting the copy (where a native Ctrl+X would let the browser cut the DOM itself); 2df0e85c reports that fallback as a warning through the editor's logger, so a bug in the uncut serialization shows in the log rather than only as a copy carrying the annotations' cuts. No input is known to make it throw.
  • df1f46b6: merging a mark-cut text into a note embed's contents ops no longer relies on quill-delta's constructor aliasing its array.
  • 933492f4: an annotation's start/end classes skip a paragraph's gutter glyph, which the gutter view draws away from the text (the box lost its leading edge); the oracle's edge grading mirrors it.
  • 2d27143e: calling-context TSDoc on every exported $ helper this PR adds, per the repo rule; the undo-step finalizer says why its lazy registration adds no commit.

Checked and dismissed: an edit batched into one commit with setAnnotation (no host batches one; forced, the destroyed-owner pend still happens); the IME composition key across a piece join (PointType.set moves it); setTextContent on one piece of a cut token (no production writer reaches one); $charNeedsSentinelAs's per-span scope walk (zero or one such span per scope); the collab fast path's block-window diff (~2 ms per keystroke at 50 verses, and the suggested gate would drop char attributes again); window.event in AnnotationPlugin (Lexical 0.43 dispatches inside the event and uses the same check); the finalizer's private _cloneNotNeeded (typecheck and two tests catch a rename, and it fails safe).

Head 07535a23 (the warning plus a dist rebuild): platform 5568 again, typecheck 0, committed dist verified current. Gates on 290d06fa: shared 1070, shared-react 2463 (+1 skipped), platform 5568 (57 expected failures, 142 skipped), 2SA 3/3, clipboard families standard 14/14, expandedNotes 14/14, unformatted 17/17, protectedNoteShell 4/4, utilities 101, lint 0 errors, typecheck 0, committed dist rebuilt and verified current. paranext-core #2889 (dd9223a4920) with this editor staged: typecheck, lint 0 errors, extensions vitest 6303, E2E annotation shapes, annotation positions and gutter markers pass.

Test results (before the rebase)

All on 244181fb (merges #11's head 02f8a1ae):

  • libs/shared 1006 passed; libs/shared-react 2050 passed (1 skipped); packages/platform 4153 passed (17 skipped; the 3 new skips are PT-4370: Settled positions across the public position API #11's own skipModes), including the transparency oracle's edge grading (each annotation's first and last painted element carry the edge classes, nothing else does), including every oracle: transparency (49 rows across the four views, with paste, Enter and Shift+Enter cases; lists empty), pending/settled and annotation locations (PT-4370: Settled positions across the public position API #11's lists unchanged).
  • Transparency oracle with the clipboard families on (ANNOTATION_TRANSPARENCY_CLIPBOARD=1; off by default for runtime, like the 2SA oracle): standard 14/14, expandedNotes 14/14, unformatted 17/17, protectedNoteShell 4/4, lists empty.
  • 2SA annotation oracle (ANNOTATION_ORACLE_2SA=1): 8/8.
  • utilities 63 passed; scribe 10 passed.
  • Lint: 0 errors (warnings only, in files this PR does not touch). Typecheck: 0 errors.
  • rebuild-committed-output + verify-committed-dist.mjs: committed output is current.
  • paranext-core (#2889 at 3947850d6ff), with this editor staged: typecheck, lint, extensions vitest platform-scripture-editor + legacy-comment-manager (4163) and E2E (the shapes spec now also asserts the edge classes) (annotation positions + shapes, space-run positions, attribute-display settle, footnotes pane, unclosed-note close/undo, find "Jumping to a result" ×7; 13/13) all pass.
  • Known flake, not from this PR: noteCaretPlacement "leaves nothing behind when the caret moves on without typing" leaves a ZWSP under full-suite load — 2 of 28 interleaved runs fail on PT-4370: Settled positions across the public position API #11's own head 29b2ceb0, 3 of 28 here.

Independent review

Three read-only Opus review rounds, the first over the whole diff and the next two over the fixes (data loss / screen≠saved, readers bypassing the helpers via the grep safety net over ~780 call sites, piece-normalization performance, collab ops, decorator sub-span rendering, test falsifiability). Round 1: 3 major, 5 minor, 3 nit — all fixed with a failing hand test first (a cut note opener's hold lost on a typed-closer close; a cut verse arming a whole-verse Delete; comment-panel add/delete merged into the previous undo step; Ctrl+Space reading a piece; a continuation chaining to an adjacent carrier of the same class; paste keeping piece states; oracle accounting and hold checks). Round 2 over the fixes: 0 blocker/major; 3 minor (oracle accounting could not fail, a spelling that did nothing was dropped silently, a crossing's hold checked by text rather than byte index) and 3 nits — all fixed. Fixing the accounting surfaced 312 spellings in the protected-note-shell view that the shell's caret guard moves to the other spelling of the same position; they now count as covered only when that spelling compared.

A third round reviewed the paste fix below: 0 blockers; 2 majors (a paste of zero nodes still removed the selection and split a carrier; a structure-protected paste bypassed the clipboard-nodes command and so the pre-split) and 4 minors (oracle could not see a wrongly dropped hold, Lexical's merge rule asymmetry, insertion point vs. editor selection) — fixed with failing tests first; the asymmetry is pinned by a test against Lexical's own merge (it never fires in Lexical 0.43).

A fourth round (/code-review high over this PR and core #2889) raised 7 findings. Fixed (e6a0174d, 042765b5, plus core 1af150429c7): the ADR, the invariants and this README now name data-piece-continuation as the way to style a cut carrier's first piece alone; the verse transform calls setNumber once (it already writes every piece); the copy path's annotation check reads the node states directly instead of serializing each selected node; two comments still described highlight painting; one comment line was over width. Dismissed: a Lexical field rename in the undo-step finalizer is already caught by tsc and two tests (whether to keep reading the private _cloneNotNeeded there is under discussion); an edit that changes no text batched into one commit with an annotation update has no reachable effect.

Judgment calls

  • A host's setAnnotation/removeAnnotation merges into the current undo step; an undo that reverts the edit an annotation was set on reverts the annotation too and reports nothing (a host re-sets annotations from its own state, as after a reload). Cost if wrong: a host that keeps per-id state could hold a stale entry after an undo.
  • A text that is one no-break space inside a note is a display spacer only when it is not content an annotation mark cut off ($isNbspContentAtMark, the rule the editor→USJ conversion already used): an authored no-break space after a span's separator now reaches collab ops as it already reached getUsj().
  • A pended note caller keeps waiting while an update only re-cuts the opener beside it (Lexical dirties a neighbour whose sibling links change); the departure settle still puts it right.
  • A chain id (the head's key at first cut) rather than a per-class identity comparison decides which pieces belong together, because adjacent carriers spelled alike (\v 1 \v 1) cannot be told apart by identity.
  • The transparency oracle plans crossings to start/end on content bytes, never on the whitespace beside a carrier (a mark edge on a space splits the space run the whitespace rules read as one), and never on a marker's separator (which nothing holds).

Since the first draft

  • Hand QA: done (TJ).
  • Merged PT-4370: Settled positions across the public position API #11's head dedc4033. Resolutions: one \id-line mark capture/restore (PT-4370: Settled positions across the public position API #11's $bookScopeSpans one; this branch's duplicate removed), this branch's note-closer carry kept beside it, PT-4370: Settled positions across the public position API #11's boundary comparison for a mark over just a note kept; PT-4370: Settled positions across the public position API #11's leftover-display-space rule and its verse-at-line-end caret restore read through marks and pieces (two hand tests that fail on the unrouted versions).
  • Gaps the first draft left open, each with a failing test first:
    • A user's lone no-break space at a mark's edge is kept by the collapsed-note copy ($isNoteInternalDisplaySeparator), the collapsed-note preview (which also skipped a mark's text entirely, and a span a mark wraps whole) and scribe's save (which also lost a comment holding just that space).
    • Enter / Shift+Enter at a mark's end before a glyph: the caret restore runs on KEY_ENTER_COMMAND/INSERT_PARAGRAPH_COMMAND/INSERT_LINE_BREAK_COMMAND; a note's \fp break lifts out through marks and lands after a cut glyph's last piece; a break between a carrier's pieces splits the carrier as paste does. Enter and Shift+Enter are now transparency-oracle keystrokes at every site (49/49, empty lists).
    • Shift+Enter tearing a char span: the result no longer depends on which half's transform runs first, and a torn attribute span spells its attributes once (plain documents too: offsets 1/5 saved |g\w* twice before).
    • Shift+Enter in a chapter-side \ca/\cp span threw (plain documents too); it now does nothing.
    • Continuation pieces carry data-piece-continuation; the table-row glyph padding applies to the head piece only.
    • $noteBytesPastCaller with a cut caller and settledOnlyRuns' \cat beside one: shown unreachable (the settled side is rebuilt uncut); the first is pinned by a test.
  • Copy, cut and paste behave exactly as with no annotations (merged fix-clipboard, b5d1ae2e). New flag-gated transparency families: cut (byte before/after, a range across a piece or mark edge, the whole block); the clipboard compared by what each flavor pastes as in a fresh editor; multi-byte copy-paste at and over the site; Shift+Left/Right then type/Backspace/Delete in every view; plain-text one- and two-line paste; plus hand tests that a pasted copy of annotated bytes never carries an annotation (ranges cover only the source, no onRemove, nothing pasted painted, one zmsc pair). Root causes fixed, each with a failing hand test first:
    • A copy never mutates the document: the clipboard is written from an uncut serialization of only the blocks the selection touches ($uncutClipboardData), so a cut verse no longer pastes back as four verses and mark-split content as separate spans; a copy touching no annotation takes Lexical's own path.
    • Pasted holds survived a paste into an editor that had not yet read the piece state (Lexical keeps unparsed node state from JSON); pasted pieces are re-read from their own JSON before stripping. A pasted part of a cut token pastes as the whole token.
    • Range ends beside a mark resolve as Lexical resolves them without the mark (on screen reads only); a range starting at a mark's edge is removed first and the caret put back at that edge; typing over a glyph's first bytes after a mark goes into the glyph (CARET_IN_GLYPH_TAG); a paste into an attribute run removes a mark-held range first.
    • A span Lexical splits for a paste or line break is recognised as the second half of that span (nearest earlier same-marker span, across whole spans the insertion put between and across a chapter's root \ca/\cp) and re-tokenized rather than unwrapped, so no closer or attribute run is lost.
    • A paragraph break or multi-line paste between a carrier's pieces splits the carrier the way its uncut text splits; a note's content texts a mark cut are one insert in the note embed's collab ops.
    • The Standard-view copy walker reads the range's last text whole when the range ends past it (a range ending past a collapsed note had lost its \f* — wrong with no annotation too).
  • Plain-editor clipboard bugs found along the way (wrong without annotations, on main too) are filed as PT-4872 (11 cases). One was a regression from PT-4370: Settled positions across the public position API #11 (fc71a2c2: a paste over an unclosed note's \ft renamed it to \ftnote); its fix is handed to PT-4370: Settled positions across the public position API #11 (fix-note-paste-pr11) and arrives here with the PT-4370: Settled positions across the public position API #11 merge that includes it (not yet in PT-4370: Settled positions across the public position API #11 at 02f8a1ae).
  • Merged PT-4370: Settled positions across the public position API #11's head 02f8a1ae (includes main's PT-4611). PT-4370: Settled positions across the public position API #11's new verse-end caret ruling, caret-in-rest rule, first-in-span spacer rule and leading-space rules now read through pieces and marks ($endsItsLine, $logicalPointOffset, $setLogicalText, throughMarks); every other new structural read in PT-4370: Settled positions across the public position API #11 was checked and needed no routing.
  • From a /code-review high pass over both PRs and the discussion after it:
    • Edge classes (8f33847e, 3d07d4e1; see Approach), with hand tests for one element, a verse piece before text, a crossing into a char span, across paragraphs, two overlapping annotations of one type, a decorator sub-span, typing at the end, deleting the first element, a re-created piece, removal, a setUsj reload, and an annotation running into a collapsed note (edges skip the hidden content and move on toggle). The vendored stylesheet strips box styling from a <mark> inside a collapsed note, which draws an empty box otherwise.
    • Hovering any piece of a cut attribute run lifts the whole run (ad8ddab4: :has(> .attribute:hover) > .attribute).
    • The undo-step finalizer keeps an update's own step when Lexical's private written-node record (_cloneNotNeeded) is missing or empty (3815f70b), with a canary test pinning that Lexical still fills it.
    • VerseNode.setNumber is called once instead of per piece; the copy path reads the hold and piece states directly; README names data-piece-continuation; stale highlight wording removed (e6a0174d, 042765b5).

Open

  • Bytes a paste splits off a glyph or verse become content, and content holds annotations only in marks; a mark there would cut runs that per-node readers (tier 2's run-on opener name, caret reports beside a caller) read whole, so those holds are dropped instead and the host hears destroyed once for an annotation left holding nothing. An annotation that keeps bytes in the glyph shrinks silently.

  • The transparency oracle has no structure-protected view; protected pastes are covered by hand cases in annotationPaste.test.tsx.

  • The host's verse-arrival pulse lands on a cut verse's head piece: a few pixels off, and only while an annotation cuts that verse number. Accepted.

  • :hover on an annotation that paints several elements lights only the element under the pointer, and onMouseEnter/onMouseLeave fire per element. Pre-existing on main for marks on both sides of a char span's edge; left for a later design (for example, letting the host set a state class on an annotation).

  • The clipboard families run only with ANNOTATION_TRANSPARENCY_CLIPBOARD=1 (they roughly triple the oracle's runtime). Where the plain editor itself contradicts its own clipboard (a paste Lexical refuses, a paste that edits the next block, flavors that paste differently from their own text/plain) the case is skipped with that reason, so an annotation-only divergence inside those cases would go unseen.

Relationship to paranext-core #2889

Based on main now that #11 and core #2823 are merged. The core side (paranext/paranext-core#2889) updates the E2E specs that asserted CSS highlights, the editor-dom.util fixtures, the annotation-style CSS generator and the standards docs/ADR.

Merge order: this PR → move platform-yalc → core #2889.

AI-assisted — session

🤖 Generated with Claude Code


This change is Reviewable

@tjcouch-sil tjcouch-sil changed the title PT-4370: One annotation mechanism — every annotation paints as classes on elements PT-4370: Paint every annotation as classes on elements Oct 15, 2026
Base automatically changed from pt-4370-settled-positions to main October 21, 2026 21:40
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
tjcouch-sil force-pushed the pt-4370-unified-annotations branch from 244181f to 0bc7185 Compare November 5, 2026 16:57
tjcouch-sil and others added 8 commits November 5, 2026 15:33
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…
…gh pieces

A `\c N` glyph an annotation cuts into pieces is one glyph: inline insertion
on the chapter line goes after its last piece, a caret in any piece is not
past the number, and a structure-protected paste at the end of its last piece
is the end of the line. An empty char span's zero-length op is left out of a
block's delta window, so text typed over the span's stand-in emits only the
insert.

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

- A char span's own-child signature leaves a caret host out of the text run it
  reads through marks, as the signing loop leaves the host out.
- An annotation's carrier split at a paste runs only when no handler inserts
  the nodes its own way, so a range paste is still a range when the inline
  range handler reads it, and prose typed at a cut carrier's piece edge lands
  in the carrier, as it does in the uncut one.
- The pending/settled oracle installs its one-character `modify` when a
  keystroke case first runs, not when its module loads, so a test that only
  imports its corpus keeps its own `modify`.
- The cut line-final verse's caret restore is checked with the last caret
  elsewhere, since a host handing back the location it read now gets that
  very caret back.

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

Main's prose paste types a pasted text over the selected range. A range whose
ends an annotation mark alone puts in different parents is removed first, with
the caret put back at the mark's edge (`$removeRangeHeldByMarks`), so the text
goes where it goes with no mark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ead of overriding it

`.formatted-font.marker-editable .verse { background-color: transparent }` outranked every
annotation class rule, so an annotation's background never painted a verse number it held in
Standard view. The grey badge is now given only to the views whose verse is a read-only number.

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

Enter in front of a space an annotation holds left the new paragraph starting with that space in
the hidden-marker views: the trim read only the paragraph's first child, which was the mark.

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

The wrap restarted its span at every annotation mark, so `\nd` over "the Lord God" with a
comment on "Lord" saved two spans. A mark the selection's end cuts is split there, a mark the
selection holds whole (edges included) moves into the new span like a whole note, and a mark that
opens the span gets the opener's separator in front of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tjcouch-sil
tjcouch-sil force-pushed the pt-4370-unified-annotations branch from 2828829 to ac6b99e Compare November 5, 2026 21:37
tjcouch-sil and others added 12 commits November 5, 2026 16:48
…ficity

`.formatted-font:not(.marker-editable) .verse` (0,3,0) outranked the gutter view's
`.psc-gutter-markers span.verse { background: none }` (0,2,1), so every verse number in the
paragraph-structure view drew the grey badge again. `:where` keeps Standard view out of the badge
at the specificity a plain `.formatted-font .verse` had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every exported `$` helper the annotation work added now says whether it mutates and where to call
it, as the repo's TSDoc rule asks: a module sentence covers displayPieces.state.ts's read-only chain
helpers, bare "Mutating." labels name `editor.update()`, and the undo-step finalizer says why its
lazy registration adds no commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
$nextSiblingThroughMarks and $previousSiblingThroughMarks found the
neighbouring boundary with getIndexWithinParent() and then read the
child back with getChildAtIndex(); both walk the parent's children in
Lexical, so every step cost the node's position. $textRunThroughMarks
steps once per run member, which made the whitespace display transform's
run building quadratic over a paragraph: tens of milliseconds for a
50-verse paragraph with dense marks, over a second for 300 verses.

The four helpers now share one walk that starts from the neighbouring
sibling and moves by sibling links only, with the same answers: marks
entered and left, no other parent left, and a carrier cut into pieces
named by its first piece going backwards. A 6000-text paragraph run now
takes a few milliseconds against about a second before; the new test
bounds it far above the linear cost.

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

A span Lexical tears in two to insert a whole span of the same marker
holds that span between its halves. The deletion rules pair the second
half back to the first past such whole spans, but $tornSecondHalfOf
took the first same-marker span after the first half and gave up when
it was not torn. So the first half, which keeps the span's attributes,
was kept whole as a sentinel while the second half's attribute run
re-tokenized too: the file got the attribute twice (`\wgr|g\w*\wz\w*
ace|g\w*`) and the screen showed something else.

$tornSecondHalfOf now walks past whole spans of the marker the way
$firstHalfOfSplitSpan does, so both pairings find the same halves. The
torn-span and scope helpers state their calling context.

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

The torn-span pairing walks every node of the span's settle scope in
document order, and a paragraph's scope nodes include the content of an
expanded note, which settles as a scope of its own. So a paragraph span
whose opener was deleted could pair with a note's span that had lost
its closer in the same update: the deletion transform routed the
paragraph span to a rebuild of the note instead of unwrapping it, and
the screen kept `def\w*` while the file got `\wdef\w*`.

Both pairing directions ($firstHalfOfSplitSpan and $tornSecondHalfOf)
now skip a candidate whose settle scope is not the span's own.

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

A copy touching annotations is written from a scratch editor that
parses the document without the annotations. That scratch editor throws
every error on purpose, since parseEditorState otherwise returns the
document as far as it got and the copy would silently drop bytes. But
nothing caught the error, so a copy that could not be written that way
threw out of the copy command and wrote nothing.

$uncutClipboardData now returns undefined when writing the uncut copy
throws, and both callers already read undefined as "use the editor's own
copy". The new test makes parseEditorState throw during a Standard-view
and an unformatted copy of an annotated paragraph and checks the copy
still writes the paragraph's text and an internal flavor.

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

A text an annotation mark cut off the text before it joins that text's
op in a note embed's contents. The merge was written as
`new Delta(contentsOps).push(textOp)` with the result dropped, which
changed contentsOps only because quill-delta's constructor keeps the
array it is given rather than copying it.

The merge is now made on a Delta of the last op alone (the only op push
reads when no delete is among them) and spliced back into the array the
note op holds, so it no longer leans on that aliasing. "a note whose
content a mark cuts" still pins the result, and fails with a plain push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion's edges

An annotation's edge classes go on the first and last element it
paints, and $edgesOf left out only content a collapsed note hides. In
the gutter view a range from the end of one paragraph into the next
paints the next paragraph's `\p` gutter glyph, which the view draws in
the gutter: the start class landed there, and the box drawn over the
text had no leading edge.

$edgesOf now leaves gutter glyphs out too, and the transparency
oracle's edge grading leaves them out the same way.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he editor's own copy

The fallback kept a copy working when the annotation-free serialization threw, but swallowed the
error, so a bug there would only show as a copy carrying the annotations' cuts. The copy handlers
now pass the editor's logger down, and the fallback reports the error as a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant