Skip to content

PT-4608: Keep chapter-line edits from losing text or the caret - #19

Merged
tjcouch-sil merged 39 commits into
mainfrom
fix-chapter-line-absorbs-text
Oct 23, 2026
Merged

tjcouch-sil merged 39 commits into
mainfrom
fix-chapter-line-absorbs-text

Conversation

@tjcouch-sil

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

Copy link
Copy Markdown
Member

Summary

Three ways editing at an editable chapter line lost what the user typed, found while working paranext-core#2816 (PT-4608), which puts a later chapter's marker back when the user deletes or damages it, because Paratext refuses a later chapter without one.

  1. A deletion that started on the chapter line silently dropped text on save. Lexical merges the block a deletion ends in into the block it starts in, so selecting from the chapter line into verse 1 and pressing Delete merged the rest of the paragraph into the chapter line. A chapter serializes as its number alone: the text stayed on screen and never reached the file. Every gesture that deletes a selection did it (Delete, Backspace, typing over the selection, cut, Enter, paste), including select-all-and-type, which saved nothing but \c N.
  2. Typing over such a selection came out reversed ("ewn" for "new") once (1) was fixed, because resetting a prefix-less paragraph to \p parked the caret ahead of the character just typed.
  3. setUsj skipped a document that undid a pending marker edit, so the host's correction never reached the screen and the caret went astray. Backspace a chapter's number out of its glyph: the edit is pending, so the glyph and getUsj() say the chapter has no number while node state still says 2. setUsj compared the incoming document against editedUsjRef, which is deserialized from node state, so the host's corrected document (number put back) matched and the load was skipped. The numberless glyph stayed on screen. The host then placed a caret just past the number, which didn't exist, so the caret stayed where focus() left it: the end of the chapter. The next keystrokes deleted the chapter's last characters, with nothing on screen to say so.

It also makes Enter on the chapter line start a new paragraph after it. Enter there had always done nothing, so there was no way to add a paragraph after \c N from its own line.

It also stops a chapter line's bytes from going unsaved once they stop being a chapter marker. Backspace the c out of \c 2, type another marker over the \c, or type ahead of it, and the bytes no longer tokenize as a chapter. The settle used to refuse them and keep them as pending literal text while the saved document fell back to the stored number: the file kept \c 2, the screen kept \ 2 indefinitely, and anything typed there was never saved.

Changes

  • ChapterNode.canBeEmpty() returns false (libs/shared). Lexical then never merges a block into the chapter line, and it removes the chapter once every byte of its marker is deleted. A deletion across the chapter line now deletes the chapter marker, as it does in the USFM text, and leaves the rest of the paragraph in its own paragraph. The host restores a later chapter's marker when it saves.

  • Enter on a chapter line starts a new paragraph after it (chapterLine.utils.ts). With nothing to merge into, Lexical has no block to split there, and left alone it put empty paragraphs at the end of the document (Shift+Enter threw). A chapter line holds nothing but its own marker, so wherever the caret is on it, Enter now starts a paragraph right after the chapter line and moves the caret into it, the way Enter after \c N adds a paragraph in the USFM text. It goes directly after the chapter even when a \cp paragraph follows, as in Paratext 9: a \cp can stand on its own, so a paragraph between the two is what the user asked for. A plain Enter gives \p. A marker-menu pick (EditorRef.splitParagraphWithMarker, or a paragraph picked through applyMarkerMenuSelection) gives the picked marker; those splits bypass INSERT_PARAGRAPH_COMMAND, so $splitParagraphWithMarker handles the chapter line itself. Before this branch, Enter on a chapter line did nothing. Shift+Enter is refused, including over a selection lying wholly on the chapter line, which is left as it is.

  • The marker menu counts the chapter the caret is on as coming before it (markerMenuContext.utils.ts), since a paragraph applied there goes after the chapter. Without this, the Enter menu on a later chapter's line saw no chapter ahead of the caret, took the caret for the book introduction, and put \ip first instead of \p.

  • The \p reset keeps the caret after the typed text (markerEditDeletion.utils.ts). It moves the caret only when it would otherwise sit in front of the new prefix, as the prefix injection already does for a split.

  • setUsj keeps an edit in progress, and a host can force a correction (Editor.tsx, new exported SetUsjOptions). While a marker edit is in progress the editor holds two documents: what is on screen (what getUsj() settles) and its node-state record from before the edit. setUsj now does nothing when handed EITHER, so a host re-sending the text as it was does not throw the edit, the caret and the undo history away, and an echo of what is on screen does not reload. But a correction can be exactly the pre-edit record — a chapter number the user backspaced away, put back — and the editor cannot tell that from a re-send, so the host says which it means: setUsj(usj, { force: true }) loads regardless. Marginal passes the option through; the API report is updated. SetUsjOptions and force are marked EXPERIMENTAL:, like applyUpdate, so they can still change without a breaking release.

  • A chapter line whose bytes stop being a chapter marker is saved as what they are (tier2Rebuild.utils.ts $rebuildChapter and chapterFragmentContent, mirrored in virtualSettle.utils.ts $settledChapter so getUsj() sees it straight away). The chapter's region is replaced by what its bytes tokenize to: c 2 becomes a markerless line of text, \ 2 a paragraph with no marker name (what ParatextData reads those bytes as), \q1 2 becomes a \q1 paragraph, and X\c 2 becomes X followed by the chapter, which the host's save repair then turns into \c 2 with X on the line below it, as Paratext 9 saves it. When a paragraph comes before the chapter, leading text like that joins the end of it instead (\mt1 Ruth / X\c 1 → \mt1 Ruth X, $joinLeadingLineToPreviousParagraph, mirrored for getUsj()): USFM reads text standing behind a line break with no marker of its own as part of the paragraph before it, so that is where the save and the reload put it. A space goes between them only where the line break would read as one (not after text already ending in whitespace, a verse marker, or an empty paragraph's own separator). That is what Paratext 9 keeps, since it saves the bytes it holds. The tokenizer's own \p for leading text is unwrapped, because nobody typed it, and the glyph's trailing separator is dropped. The host's save repair then puts back a marker the chapter needs above that text (paranext-core#2816). A line left empty waits for the caret to leave before the live settle removes it.

  • A nameless marker survives the USX/USJ converters (usx-to-usj.ts, usj-to-usx.ts in packages/utilities). A bare backslash (\ 2) is a marker with an empty name: ParatextData reads it as <para style=""> and writes that back as \ 2, and Paratext 9 keeps the bytes as they are. Both converters tested the name for truthiness and dropped it, so the line reached the editor with no marker, which shows it as \p, and the next save wrote \p 2. Both now test for presence, as setAttributes already does for every other attribute.

  • A note, character marker, verse or milestone inserted on a chapter line goes where Paratext 9 puts it (chapterLine.utils.ts $moveCaretOffChapterLine, called from getUsjMarkerAction and the marker menu's note path). The chapter line is no longer a block (change 1), so RangeSelection.insertNodes threw there ("expected a block ElementNode ancestor") — EditorRef.insertMarker("f") with the caret where paranext-core leaves it after a correction, for example. Content put after a chapter number goes, in Paratext 9 and when typed here, on a markerless line straight after the chapter line; a following \ca then follows it on that line and a following \cp keeps its own line, and neither is the chapter's attribute any more: \c 1 ^ / \ca 2\ca* → \c 1 / ^\ca 2\ca*, and \c 1 ^ / \cp 2 → \c 1 / ^ / \cp 2. The insertion gets there the way typing does: the chapter's bytes are re-tokenized ($rebuildChapter) with one character standing in for the content after the chapter number, and the caret takes that character's place. With the caret past the chapter's \ca run, the stand-in goes after the run instead, so the run stays the chapter's (\c 1 \ca 2\ca*^ → \c 1 \ca 2\ca* / ^), as typing there does. With the caret in the chapter's \cp run, the content goes on a markerless line after the chapter line and the \cp stays the chapter's (\c 1 \cp B^ → \c 1 \cp B / ^): that is what ParatextData makes of the marker's bytes put at that caret (\cp B\f …\f*), and it reads back unchanged. No re-tokenize happens there, since a stand-in after a \cp run would be read as more of the published number. Typing at that caret still goes into the published number, as those bytes do in Paratext 9. The stand-in is a Unicode noncharacter (U+FDD0), looked for only among the nodes the re-tokenize produced, so a private-use character in a project's text is never taken for it; and a separator goes before it when the chapter glyph has lost its trailing space, which would otherwise join it to the number. A selection lying wholly on the chapter line (part of the number selected) inserts in the same place rather than wrapping or replacing the number.

  • A paste or drop on a chapter line goes in as it would be typed there (chapterLine.utils.ts, MarkerEditPlugin.tsx). Lexical's clipboard insertion (a drop, a same-editor rich paste) splits at every line break by calling insertParagraph directly, and inserts nodes at the caret, and a chapter line has no block for either. So a paste or drop on a chapter line is claimed at CRITICAL and inserted through the same code main's Standard-view paste uses (its insertion tail, extracted unchanged as $insertPastedText): \c/\id dropped, NBSPs normalized, the first line onto the chapter line and each later line a paragraph after it. A drop reads its data as a paste does (getDataTransferPayload, Paratext 9 html decoding included).

  • Structure protection covers pastes (whitespaceDisplay.plugin.utils.ts, chapterLine.utils.ts). Under structureProtectionMode: "protected" the keyboard cannot add a structure marker (Platform.Bible's marker menu refuses every paragraph marker and \v), but a paste could: pasted text re-tokenizes, so aa \v 9 bb added verse 9, and a same-editor copy (which went to the html sanitizer) added a verse and split the paragraph. A protected paste now drops every marker the stylesheet types as a paragraph, and \v with its number (stripPastedBlockMarkers), keeping the text; character markers, notes and figures stay. This applies to a paste into or touching attribute text (a |… run, a \va value) too: there \v 9 became a verse and \p split the paragraph. A protected same-editor copy takes the same path instead of the sanitizer. A marker the stylesheet does not know is kept: nothing says for sure that it is structure, and the stylesheet the editor is given includes the project's custom.sty, so a marker it does not know could be a project's own (it is read as a paragraph in body text, as Paratext 9 reads it, and as it is when typed). On a chapter line, a protected paste that would rewrite the chapter's own marker (a range touching the line, or a caret anywhere but just past its end) is refused and changes nothing, as pasting over a verse number is. docs/clipboard-semantics.md describes the three things protection now changes, including in attribute context.

  • The caret's place on a chapter line that is no longer a chapter is reported exactly (positions/settledScopes.utils.ts). With PT-4370: Settled positions across the public position API #11's settled positions, getSelection() reports the caret in the document getUsj() returns, by comparing what is on screen with what it settles into. A chapter scope's settled side was built only when it still held a chapter, so for \ 2 or \sc 2 a caret anywhere on the line came back as the start of the line, and paranext-core#2816's repair could not put it back where the user was. It is now built as any other settled root content, so the caret is reported at its place: $.content[0]['marker'] just past the backslash, or an offset in the line's text.

  • A chapter line that settles into more than its chapter is read the same way by every reader ($settledChapterScopeFragment in tier2Rebuild.utils.ts). With text typed ahead of the marker or the line become \ 2, both settles carried annotation marks only when the chapter came first, so a mark beside the chapter was dropped; and when that text joins the paragraph before the chapter, the position plan did not know, so while the edit was pending every top-level index from there on was reported and resolved one item off. The chapter's position plan now takes in the paragraph a leading line joins.

  • A key acts on the caret the browser just moved (KeyDownCaretSyncPlugin, mounted first among the critical-priority key handlers). The browser moves the caret itself and reports it through selectionchange, which it fires asynchronously, so a key handled before that event acted on the old caret: Home then ArrowRight on a chapter line could jump to the next paragraph, and End, ArrowLeft three times and Backspace could delete the line's trailing space instead of the c. When a key arrives and the DOM's caret reads back as a different position from Lexical's, Lexical's is replaced; a caret at the DOM position Lexical renders is kept, so a position the editor chose among several at one DOM point is left alone. This is editor-wide and was the same on main; it is here because paranext-core#2816's chapter-line specs hit it.

ChapterNode is shared with the scribe package, so (1) changes scribe's chapter-line deletion the same way.

Known limitation

Under protection, dragging html onto a chapter line still reaches StructureKeyboardPlugin's drop sanitizer, which throws Lexical's "expected a block ElementNode ancestor" invariant there, so the drop does nothing (reported through onError; the editor state is left as it was). It needs the caret to have been on the chapter line before the drop, since that handler inserts at the pre-drop selection, and nothing is lost. Typing over the chapter number in a protected document still changes it: protection does not treat the chapter marker as structure. Left alone on purpose.

Relation to #11

Change 3 is the same kind of bug as #11 (PT-4370, merged 2026-10-21): a pending edit makes the live tree and the settled document disagree. #11 translates positions between the two in getSelection/setSelection; this fixes setUsj's equality check. Merging the two showed where they met: #11's position mapping assumed a chapter scope settles into a chapter, which this branch's change to chapter-line settling broke (the first two changes above, after "Structure protection covers pastes").

Testing

  • chapterLineEditing.test.tsx: every deleting gesture across the chapter line (Delete, Backspace, both selection directions, typing over, typing a key at a time, cut, Enter, multi-line paste, select-all-and-type) keeps the text, read back through getUsj(), including a chapter line only partly selected and a chapter whose marker alone is deleted. On the chapter line itself: Enter at its start, middle and end starts a \p after it (undone in one step, and before a following \cp), marker-menu picks start the picked paragraph, the Enter menu puts \p first, Shift+Enter does nothing (also over a selected number), a paste, a drop and an html-only drop go in as typed, a pasted \c is dropped, a same-editor paste works under protection, and protected pastes that would rewrite the chapter marker are refused.
  • protectedFigurePaste.test.tsx: under protection a pasted verse or paragraph marker is dropped with its text kept (plain text, multi-line, a same-editor copy, and inside or touching an attribute run), a character marker is kept, and the unprotected paste still builds them.
  • chapterLineEditing.test.tsx (insertions): a note, a character marker and a verse inserted on a chapter line, with no attribute, a \ca and a \cp, each give exactly the document ParatextData makes of the same USFM (checked by round-tripping \c 2 X, \c 2 \f + \ft N\f*, \c 2 \nd X\nd*, \c 2 \v 9, and each followed by \ca 3\ca* or \cp B, through a Paratext project); the same from a caret just past the number, over a selected digit, with nothing after the chapter line, and from the marker menu; and typed text, the reference the insertions follow, lands the same way.
  • setUsj.test.tsx: with the chapter number backspaced out (pending), setUsj of the document from before the edit leaves the edit and its caret alone; the same document forced loads it and a caret just past the number resolves; handing it the document on screen changes nothing; any other document loads.
  • Every new test was run against the code with its fix removed and fails there.
  • Full packages/platform suite: 2,000 passed across 110 files (at 2d6ec2d7, and again at 89ad7ed2); typecheck and lint clean for platform and scribe. CI's lint step had failed on one Record<string, string> in chapterLineEditing.test.tsx, which the repo's lint wants as an index signature (89ad7ed2, test-only, so the committed build output is unchanged). rebuild-committed-output then verify-committed-dist reports the committed build output current. d2ca50e4 rewraps an over-long comment in chapterLine.utils.ts (only the source map changes), and verify-committed-dist again reports the committed build output current.
  • At e9720406, merged with main (PT-4730: Limit how much text can be copied from the editor #23, the copy limit) on 2026-10-01: full packages/platform suite 2,154 passed / 3 skipped across 112 files; typecheck and lint clean for platform; committed build output verifies. The new insertion tests fail against the previous next-paragraph placement (14 of them).
  • At 46eb98f3: full packages/platform suite 2,161 passed / 3 skipped across 112 files; typecheck and lint clean for platform; committed build output verifies. The new insertion tests (a private-use character elsewhere, the caret past a \ca run for a note, a character marker and a verse, a glyph without its trailing space) fail against e9720406, and typing past a \ca run is pinned beside them as the reference.
  • At 9a60a0bb: full packages/platform suite 2,167 passed / 3 skipped; typecheck and lint clean for platform; committed build output verifies. The six new tests (a note, a character marker and a verse inserted from the end of a \cp run, with and without a \ca before it) fail against 46eb98f3.
  • At 90604b3f: full packages/platform suite 2,174 passed / 3 skipped; typecheck and lint clean for platform (one pre-existing no-loop-func warning in verse-block.utils.ts); committed build output verifies. Seven new chapter-line tests (\ 2, c 2, \, an emptied line, text typed ahead of the \c, the line once the caret leaves, \q1 typed over the \c) fail without the fix. The test that pinned the old refusal (\q1 1 over a chapter with \ca) now pins the paragraph it becomes.
  • At 0f949073: packages/platform 2,174 passed / 3 skipped and packages/utilities 60 passed; typecheck and lint clean for both; committed build output verifies. The new nameless-marker.test.ts fails without the converter fix (three of its four cases; the fourth pins that an element with no marker still gets no style).
  • At 5bd302c3 (merged with main, Fix the editor context menu and \id-line editing #14, PT-4189: EditorRef note index/key/highlight and keep DOM focus on unfocused applyUpdate #15 and Rest the caret at an empty verse's editable marker, not between markers #24, on 2026-10-14): full packages/platform suite 2,435 passed / 3 skipped across 126 files, libs/shared 713 and libs/shared-react 1,766 passed; typecheck and lint clean; committed build output verifies. The eight new tests for text typed ahead of a chapter marker (a paragraph before it ending in text, a space, a note, a verse marker, or nothing; leading whitespace; a markerless line before it; the caret) fail with the join removed from both settles. In the running app with paranext-core#2816, all seven chapter-* specs pass, including the new \mt1 Ruth X step.
  • At 3d8c3aa0 (merged with main, Let a host suspend the gesture-free marker-settle clocks (unblocks PT-4611) #25, on 2026-10-19): full packages/platform suite 2,457 passed / 3 skipped across 127 files; typecheck and lint clean for platform-editor, shared, shared-react and utilities; committed build output verifies.
  • At ceb92b56 (merged with main, PT-4370: Settled positions across the public position API #11 and PT-4540: Make a paragraph's gutter marker selectable #22, on 2026-10-22): packages/platform 4,071 passed / 17 skipped, libs/shared-react 2,097, libs/shared 895 and packages/utilities 64 passed; typecheck clean for every project; lint clean for platform-editor and shared-react apart from existing warnings; committed build output verifies. The merge conflicted in $rebuildChapter/$settledChapterScope (kept this branch's settle-to-what-the-bytes-are and main's annotation carry and $captureCaret; chapterFragmentContent now drops display-only line breaks itself, as main's tokenizer path does), in the nameless-marker converters and their test (both branches had added the same behavior; main's wording kept, plus one case only this branch tested), and in one test comment. Three tests were updated to main's rules: a paragraph's leading space is not saved (PT-4370: Settled positions across the public position API #11 c3b2f878), a caret past the chapter number is ['number'], and PT-4370: Settled positions across the public position API #11's "refuses a footnote inserted through insertMarker" now pins this branch's placement after the chapter line, where the note is saved. Each new test (the caret on \ 2/\sc 2, the joined paragraph's positions and a host annotation after it, marks across text typed ahead or \ 2, and the key-press caret) fails without its fix. In the running app with paranext-core#2816, the seven chapter-* specs and the palette specs pass.
  • In the running app (paranext-core#2816 with this branch staged at 2d6ec2d7), the four chapter-marker specs, the new chapter-switch-edit-pause spec and clipboard-usfm-round-trip pass.

Consumer note

paranext-core#2816 depends on this: it forces its correction with setUsj(usj, { force: true }), and its deleted-marker handling relies on change 1. Merge this first, then move platform-yalc. Branch merged with main (including #13, the copy/paste work, and #21) on 2026-09-24, again (#23) on 2026-10-01, again (#14, #15, #24) on 2026-10-14, again (#25, the settle suspension) on 2026-10-19, where only the committed build output conflicted and was rebuilt from the merged source, and again (#11, #22) on 2026-10-22 (see Testing). That last merge had three conflicts: $splitParagraphWithMarker now handles the chapter line first and returns true (main made it return whether it split), the Enter menu's stack counts both a book line and a chapter line the caret is on, and an import list.

AI Involvement

AI-assisted. Claude found the three bugs while testing paranext-core#2816 in the running app, traced each one to its cause, and wrote the changes and tests; the later changes came from review rounds.

🤖 Generated with Claude Code


This change is Reviewable

tjcouch-sil and others added 3 commits September 18, 2026 15:34
Deleting a selection that started on an editable chapter line and ran
into the text merged the rest of the paragraph into the chapter line:
Lexical always merges the block a deletion ends in into the block it
starts in. A chapter serializes as its number alone, so the merged text
stayed on screen and was silently dropped from the saved document. Every
gesture that deletes a selection did it -- Delete, Backspace, typing
over it, cut, Enter and paste -- including select-all-and-type, which
saved nothing but the chapter number.

ChapterNode now reports that it cannot be empty. Lexical then never
treats it as a block to merge into, and removes it once every byte of
its marker is deleted, so a deletion across the chapter line deletes the
chapter marker (as in the USFM text) and leaves the text after the
selection in its own paragraph. The host restores a later chapter's
marker when it saves.

The cost is that with the caret inside a chapter line Lexical has no
block to split: Enter and Shift+Enter there landed empty paragraphs at
the end of the document (Shift+Enter threw), and a multi-line paste put
its later lines there too. MarkerEditPlugin now refuses paragraph and
line-break splits on a chapter line, as before, and takes a paste there
as one line of text with its line breaks turned into spaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o \p

Typing over a selection that took a paragraph's \p marker, where no
paragraph comes before it to merge into, resets it to \p with a fresh
prefix. The reset always parked the caret at the start of the content,
ahead of the character just typed, so every later keystroke landed in
front of it: typing "new" produced "ewn". A selection running from a
chapter line into the text now reaches this reset (the chapter line no
longer absorbs the paragraph), and so does one starting at the \p of the
first paragraph after a chapter.

The reset now moves the caret only when it would otherwise sit in front
of the new prefix, as the prefix injection already does for a split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setUsj skipped any document equal to editedUsjRef, the USJ deserialized
from node state. While a marker edit is pending, node state still holds
what was there before it: backspace a chapter's number out of its glyph
and the chapter node still says 2, though the glyph and getUsj() say it
has none. A host putting the number back -- paranext-core does, because
Paratext refuses a later chapter without one -- handed setUsj a document
equal to that node state, so the load was skipped. The numberless glyph
stayed on screen, a caret placed past the number had nothing to resolve
against, and it stayed wherever focus had left it: the end of the
chapter, where the next keys deleted text.

setUsj now compares the incoming document against the settled one --
getUsj(), what is on screen. With nothing pending that is the same
comparison as before. With an edit pending, a document that undoes it
loads, and the settled document itself is skipped, keeping the caret
and the undo history of the edit in progress where it used to reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tjcouch-sil and others added 3 commits September 21, 2026 18:32
A chapter line reports it cannot be empty, so Lexical does not treat it as
a block: a paragraph split requested there has no block to split and
lands at the document root instead, adding empty paragraphs after the
chapter and moving the caret there. Enter and Shift+Enter on a chapter
line are refused through INSERT_PARAGRAPH_COMMAND, but a marker-menu pick
(EditorRef.splitParagraphWithMarker, and applyMarkerMenuSelection's
paragraph branch) splits directly without dispatching that command.

The chapter glyph is plain text rather than a marker glyph, so a host's
Enter handler opens its paragraph menu on a chapter line, and picking any
marker there appended empty paragraphs. $splitParagraphWithMarker now runs
the same chapter-line refusal first, so the pick does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enter on a chapter line was refused, so there was no way to add a
paragraph right after \c N from the chapter line, as Enter does in the
USFM text. A chapter line holds nothing but its own marker and cannot be
split, so Enter anywhere on it now starts a new paragraph after it and
puts the caret there: a plain Enter gives \p, and a marker-menu pick (the
Enter menu, or a paragraph picked from the backslash menu) gives the
picked marker. Shift+Enter is still refused, and a paste still goes in as
one line.

The marker menu now counts the chapter the caret is on as preceding it,
since the paragraph it applies goes after that chapter. Without that, the
Enter menu on a later chapter's line saw no chapter before the caret,
took it for the book introduction, and put \ip first instead of \p.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A drop reaches the editor as CONTROLLED_TEXT_INSERTION_COMMAND carrying
the drop's DataTransfer, and Lexical inserts it through its clipboard
path, which splits at every line break with insertParagraph directly
instead of dispatching INSERT_PARAGRAPH_COMMAND. With the caret on a
chapter line there is no block to split, so dropping two lines there put
the first after the chapter and the second after the chapter's last
paragraph. A drop on a chapter line now goes in as one line of text, as a
paste there does.

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

- Enter on a chapter line starts its paragraph after the chapter's `\ca`/`\cp`
  attribute region, not directly after the chapter node: a paragraph put
  between a chapter and its `\cp` paragraph parted the two, so the `\cp` no
  longer followed its `\c` in the saved USFM.
- Shift+Enter over a selection lying wholly on one chapter line leaves the
  selection alone. The line break is refused, so deleting the selection first
  only took bytes (e.g. the number) out of the marker.
- A drop on a chapter line reads its data the way a paste does, falling back
  to the decoded `text/html` when there is no `text/plain` (new shared
  `getDataTransferPayload`). An html-only drop previously fell through to
  Lexical's rich-text insertion, which has no block to insert into there.
- `$chapterLineOf` uses `$findMatchingParent`; the chapter-line test header
  describes the behavior rather than its history.

Rebuilt packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tjcouch-sil and others added 5 commits September 24, 2026 11:50
…aratext 9 does

A \cp paragraph can stand on its own, so a paragraph put between a chapter
and a following \cp is what the user asked for; putting it after the \cp
region instead was a departure from Paratext 9. The test now pins the
Paratext 9 order. (packages/platform/dist is rebuilt with the main merge.)

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

Resolution: main's Standard-view external-paste handler now claims external
pastes at HIGH and replays them as typed, so the chapter line's LOW
"insert as one line" claim no longer ran for them. The chapter line now uses
main's own pipeline instead of a second one:

- The insertion tail of `$handlePasteForStandardView` is extracted, unchanged,
  as `$insertPastedText` (strip `\c`/`\id`, normalize NBSPs, replay lines as
  typed, or join them in a protected document).
- A paste or drop on a chapter line goes through `$insertPastedText`, so its
  first line goes onto the chapter line and each later line starts a paragraph
  after it, exactly as typing it there does.
- The chapter-line paste claim moves to CRITICAL, ahead of Lexical's rich
  paste and structure protection's html sanitizer. Both insert nodes at the
  caret, and a chapter line has no block for them: an internal paste in a
  protected document threw and did nothing.
- `getDataTransferPayload(dataTransfer, namespace)` reads a drop with the same
  carrier rules as a paste, Paratext 9 html decoding included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In a structure-protected document a chapter's marker is structure, as a
verse number is. A paste or drop on the chapter line is now refused, changing
nothing, when it would replace any of the marker or land inside it; just past
the end of the line, where typing adds text after the marker, it still goes
in as typing would (one line, no paragraph). The protection's own refusals
(a range across a paragraph boundary or a verse) are still asked first.

Rebuilt packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under structure protection the keyboard cannot add a structure marker (the
marker menu refuses every paragraph marker and \v), but a paste could: the
pasted text re-tokenized, so `aa \v 9 bb` added verse 9. A same-editor copy
was worse, adding a verse and splitting the paragraph, because it reached
the html sanitizer, which inserts the copy's USFM bytes as text.

Protected pastes now drop every marker the stylesheet types as a paragraph,
and \v with its number (`stripPastedBlockMarkers`), keeping the text around
them; character markers, notes, figures and unknown markers stay. This runs
in the one insertion path every Standard-view paste uses (`$insertPastedText`,
also the chapter-line paste and drop), and in the in-note line replay. Under
protection a same-editor copy now takes that path too instead of the
sanitizer. clipboard-semantics.md and the doc comments now describe the
three things protection changes.

Rebuilt packages/platform/dist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While a marker edit is in progress the editor holds two documents: what is
on screen, and its record from before the edit. setUsj compared an
incoming document only against the screen, so a host re-sending the text as
it was before the edit replaced the edit, the caret and the undo history.
It now does nothing when handed EITHER document.

That default alone would skip a correction that puts back what the edit
removed (a host restoring a chapter number the user backspaced away sends
exactly the pre-edit document), and the two cases look identical to the
editor. So the host says which it means: `setUsj(usj, { force: true })`
(new `SetUsjOptions`, exported) loads regardless. Marginal passes the
option through.

Rebuilt packages/platform/dist and the API report.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tjcouch-sil added a commit to paranext/paranext-core that referenced this pull request Sep 24, 2026
The editor's setUsj now keeps a marker edit in progress when handed the
document from before it (paranext/scripture-editors#19). A repair can be
exactly that document — a chapter number the user backspaced away, put back
— so the push-back passes `{ force: true }` to have it loaded.

Without it the correction still reaches the screen, but only a save later:
once the edit settles, the next save repairs it again and that load goes
through. The chapter-marker e2e specs pass either way; the editor's own
setUsj test is what distinguishes the two.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tjcouch-sil and others added 2 commits September 25, 2026 09:36
The repo's lint prefers an index signature over Record, and this one error
failed the lint step on every CI platform.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@tjcouch-sil
tjcouch-sil marked this pull request as ready for review September 25, 2026 17:00
@rolfheij-sil

Copy link
Copy Markdown
Contributor

🤖 Claude: ### Code review

Found 3 issues:

  1. Inserting a footnote, verse or character marker with the caret on a chapter line now throws. With ChapterNode.canBeEmpty() returning false, Lexical no longer treats the chapter line as a block, so RangeSelection.insertNodes finds no block ancestor. With the caret at the end of the \c 2 glyph (where paranext-core#2816 places it after a chapter-marker correction), EditorRef.insertMarker("f"), insertMarker("nd") and insertMarker("v") each throw Expected node TextNode of type text to have a block ElementNode ancestor. Reproduced in a vitest against this head. With the override removed the same calls don't throw, but the inserted node lands inside the chapter line and is dropped on save, so neither version handles it. Splits, line breaks and paste/drop are redirected in chapterLine.utils.ts; the inline insert paths ($insertNoteWithSelect, getUsjMarkerAction) are not.

*/
override canBeEmpty(): false {
return false;
}

} else {
selection.insertNodes([noteNode]);
}

// Insert the node directly
selection?.insertNodes([nodeToInsert]);
}

  1. A structure-protected paste still lets two kinds of structure through, although the description says protection now drops them:
    • stripPastedBlockMarkers keeps a marker the stylesheet doesn't know ("nothing says they are structure"), but the tokenizer treats an unknown marker in body text as a paragraph, as Paratext 9 does. Pasting aa \zz bb therefore still splits the paragraph.
    • A paste whose selection touches attribute text (a char span's |… run, a \va display) goes through $insertPastedTextIntoAttributeContext, which never applies the strip, so pasting x \v 9 y there still adds verse 9. The note above it ("A protected document needs no special case in that branch") was written before protected pastes started dropping markers.

*/
export function stripPastedBlockMarkers(text: string, getMarker: MarkerLookup): string {
return text
.replace(VERSE_TOKEN, "")
.replace(PLAIN_OPENER_TOKEN, (token, name: string) =>
getMarker(name)?.type === MarkerType.Paragraph ? "" : token,
);
}

} else {
// Unknown to the effective stylesheet: PT9 resolves by context
// (DetermineUnknownTokenType): PARAGRAPH in body text, CHARACTER inside
// a note; `esb`/`esbe` are explicitly paragraphs (UsfmToken.cs:405-421).
// A non-`+` char run closes any open char style unconditionally, same as
// a known Character token (UsfmParser.cs:247).
consumeSeparator();
const insideNote = isNoteContext || openNoteMarker !== undefined;
if (!insideNote || name === SIDEBAR_MARKER || name === SIDEBAR_END_MARKER)
tokens.push({ kind: "para", marker: name });
else tokens.push({ kind: "charOpen", marker: clean, isNested });

event?.preventDefault();
if (inAttributeContext) {
$insertPastedTextIntoAttributeContext(selection, text);
return true;
}

  1. SetUsjOptions and its force flag are new public API, marked @public but not experimental. Could they get the same EXPERIMENTAL: note applyUpdate carries, so the option can still change without a breaking release?

*/
export interface SetUsjOptions {
/**
* Load the document even when the editor already holds it. Defaults to `false`.
*
* Pass `true` to correct what is on screen while a marker edit may be in progress. The editor
* keeps two versions of its document while such an edit is in progress: what is on screen, and
* its own record from before the edit. A document equal to that record is skipped by default,
* which keeps the edit from being thrown away by a host that re-sends the text as it was. The
* same test skips a correction that happens to put back what the edit removed, such as a chapter
* number the user backspaced away, so a host making that correction forces it. A forced load
* replaces the edit in progress, and, like any load, takes the caret and the undo history with
* it.
*/
force?: boolean;
}

setUsj(usj: Usj, options?: SetUsjOptions): void;
/**
* EXPERIMENTAL: Apply Operational Transform delta update.
*

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tjcouch-sil and others added 6 commits October 1, 2026 09:01
…n a chapter line

A chapter line is not a block ElementNode (ChapterNode.canBeEmpty() is false),
so RangeSelection.insertNodes throws when an inline insertion (a note,
character marker, verse, or milestone) is requested with a collapsed caret
there -- exactly where paranext-core leaves the caret after a chapter-marker
correction. Add $moveCaretOffChapterLine (chapterLine.utils.ts), which
relocates such a caret to the content start of the first paragraph after the
chapter line (skipping its adjacent \ca/\cp attribute nodes), or no-ops when
no such paragraph exists. Call it from every platform-side entry point an
inline insertion can start from: getUsjMarkerAction's note and generic
branches (covering EditorRef.insertMarker and the marker-menu apply paths
that delegate to it) and the marker-menu apply's direct note-insertion call.

Non-collapsed selections that touch a chapter line are unaffected by this
change and are already handled upstream: $normalizeSelectionOutOfGlyphText
steps a range's endpoints past chapter-glyph text before the generic
char/verse branch wraps it, and $insertNoteWithSelect collapses a
non-collapsed selection to its end (then normalizes that) before inserting a
note -- both leave the chapter untouched in the common case. One gap remains
unfixed, out of scope here: a non-collapsed selection wholly WITHIN the
chapter glyph still throws for note insertion (the end-collapse lands back on
the glyph's trailing end before my guard runs), and the same selection shape
still corrupts the chapter's number for character/verse insertion (an
existing bug, unrelated to this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
$handlePasteForStandardView sends a paste whose selection touches
attribute-display text (a char span's |attrs run, a verse's \va/\vp run, a
milestone's attribute run) to $insertPastedTextIntoAttributeContext, which
never applied stripPastedBlockMarkers even under structure protection.
Measured: a \v 9 or \p pasted into a \va run's value settles into a real
verse or a paragraph split (stranding the run's own closer as an unmatched
marker) exactly as typing it would -- protection does not hold there.

Both selection shapes need the strip, not just the one that lands as
ordinary paragraph content: a selection wholly within one attribute node
(the paste-≡-typed value-byte carve-out) settles its structure bytes the
same way a selection that only touches the run does, so protection's rule
that a protected paste never adds structure outranks the carve-out for
exactly the bytes that would become structure. $insertPastedTextIntoAttributeContext
now takes isStructureProtected and getMarker and applies stripPastedBlockMarkers
on both branches under protection; the doc comments on it and on
$handlePasteForStandardView's now-inaccurate "needs no special case" sentence
are corrected to match.

Also rewrites stripPastedBlockMarkers's doc comment: an unknown marker's
bytes are kept because they cannot be classified, not because "nothing says
they are structure" -- in body text the tokenizer reads an unrecognized
marker as a paragraph regardless (PT9's DetermineUnknownTokenType), so a
protected paste of one can still split the paragraph, the same as typing it
would. No behavior change for that case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SetUsjOptions (@public) and the force flag it adds are new public API.
Prefix their doc comments with the same EXPERIMENTAL: convention
applyUpdate and the other experimental members of EditorRef already use, so
they can change without a breaking release, and note it on setUsj's options
param doc too.

Rebuilt the committed dist and API report (pnpm rebuild-committed-output)
to pick up this change plus the Item 1 and Item 2 source changes already on
this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… line

A selection lying wholly on a chapter line, such as the chapter number's
digit, is the same request as a caret there: a note insertion threw for
want of a block ancestor, and a character marker wrapped the selected
digit out of the chapter number. Treat it like the caret and move to
the start of the chapter's text.

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

# Conflicts:
#	packages/platform/dist/index.js
#	packages/platform/dist/index.js.map
An inline insertion with the caret on a chapter line went to the start
of the next paragraph. Paratext 9 (and typing in this editor) puts
content after a chapter number on a markerless line straight after the
chapter line: ahead of a following \ca, which stops being the chapter's
attribute, and on its own line before a following \cp. The insertion
now re-tokenizes the chapter's bytes with one character standing in for
the content at the end of the chapter glyph (the same settle typing
runs), and the caret takes that character's place.

Tests pin each shape to what ParatextData makes of the same USFM
(\c 2 ^ / \ca 3\ca* -> \c 2 / ^\ca 3\ca*, \c 2 ^ / \cp B ->
\c 2 / ^ / \cp B) for notes, character markers and verses, plus typed
text as the reference.

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

Copy link
Copy Markdown
Member Author

Replying to the code review:

  1. Inline insertion on a chapter line — confirmed; the throw reproduced for f, nd, v and the marker-menu note path. An insertion there now goes where Paratext 9 (and typing in this editor) puts content after a chapter number: on a markerless line straight after the chapter line. A following \ca then follows the inserted content on that line, and a following \cp keeps its own line, neither being the chapter's attribute any more — \c 1 ^ / \ca 2\ca* → \c 1 / ^\ca 2\ca*, and \c 1 ^ / \cp 2 → \c 1 / ^ / \cp 2. It gets there the way typing does: the chapter's bytes are re-tokenized with one character standing in for the content, which the inserted node then replaces. The tests pin each shape to what ParatextData makes of the same USFM, and cover a selected digit of the number too (which before inserted a note into nothing, or wrapped the digit out of the number). 905fda8f, 0a2335c4, e9720406
  2. Protected pastes
    • Attribute context — fixed: under protection, a paste into or touching attribute text drops structure markers too. Inside a \va value, \v 9 had become a verse and \p had split the paragraph. 911bd341
    • Unknown markers — leaving these alone for now. We don't have data that an unknown marker really changes structure: it renders like one, and the tokenizer reads it as a paragraph in body text as Paratext 9 does, but that is a reading rather than knowledge of what the marker is, and it could go either way. The stylesheet the editor gets is the project's merged usfm.sty + custom.sty, so a marker it doesn't know can still be legitimate — from another project's custom.sty in the pasted text, or pasted before this project's stylesheet has loaded — and stripping it would drop bytes we can't classify. The doc comment on stripPastedBlockMarkers now says this accurately instead of "nothing says they are structure".
  3. SetUsjOptions — done: it and force carry EXPERIMENTAL:. 02fdaa6c

The branch is also merged with main (#23).

@tjcouch-sil

Copy link
Copy Markdown
Member Author

🤖 Claude: Round 2 addressed in 46eb98f3 and e56bf965. All three insertion issues reproduced as failing tests first.

  1. Placeholder search across the whole document — fixed both ways. The stand-in is now U+FDD0, a Unicode noncharacter (reserved for a program's internal use, so not in project text the way a private-use character can be), and it's looked for only among the nodes the re-tokenize produced (between the chapter's previous sibling and whatever followed its attribute region). The comment says this now. New test: an \ip holding U+E000 ahead of \c 2 comes through unchanged, and the footnote lands after the chapter.
  2. Caret past the \ca run — fixed. With the caret anywhere on the line past the chapter number and a closed \ca run present, the stand-in goes after the run, so { chapter 2, altnumber 3 } keeps its alternate number and the content follows it. Tested for a note, a character marker and a verse. Typing at that caret ([CHAPTER_2_CA_3, "X", …]) is pinned beside them as the reference.
  3. Glyph without its trailing space — fixed as you suggested: a space goes before the stand-in when the glyph doesn't end in whitespace. Tested for insertMarker("f") and insertMarker("nd") on a \c 2 glyph.
  4. docs/clipboard-semantics.md — the attribute-context bullet now says protection drops structure markers there too, and the protected-paste section's rule (3) says it applies in attribute context on both selection shapes.

packages/platform suite: 2,161 passed; typecheck and lint clean; committed build output verifies.

🤖 Generated with Claude Code

@rolfheij-sil rolfheij-sil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Claude: ### Code review (round 3)

Thanks — all four round-2 items check out at 46eb98f3: a private-use character elsewhere in the document is left alone, an insertion past the \ca run keeps the alternate number, a glyph without its trailing space no longer throws, and the clipboard doc covers attribute context. The earlier repro tests now produce the expected documents, and the packages/platform suite passes here at this head (2161 passed, 3 skipped).

One non-blocking question, about the \cp sibling of round-2 item 2. With the caret after the chapter's \cp value ({ chapter 2, pubnumber "B" }, caret at the end of the \cp B run), insertMarker("f"), insertMarker("nd") and insertMarker("v") put the inserted item before the \cp: the result is [chapter 2 (no pubnumber), inserted, para cp "B", …]. Typing X at that caret keeps the run the chapter's (pubnumber: "BX"). $insertionAnchorOnChapterLine only looks at the \ca run, so with no \ca the anchor falls back to the number, and with both runs the insertion goes between \ca and \cp. Reproduced in a vitest against this head. Is that intended for \cp? If not, anchoring after the \cp run too ($chapterPubnumberRunPieces) would match typing; if it is, a line in the doc saying \cp deliberately differs from \ca would settle it.

*/
function $insertionAnchorOnChapterLine(
chapter: ChapterNode,
glyph: TextNode,
selection: RangeSelection,
): LexicalNode {
const { closer, wrapper } = $chapterAltnumberRunPieces(chapter);
const runEnd = wrapper ?? closer;
if (!runEnd) return glyph;
const focusNode = selection.focus.getNode();
const isPastNumber = focusNode.is(chapter)
? selection.focus.offset > glyph.getIndexWithinParent() + 1
: !focusNode.is(glyph);
return isPastNumber ? runEnd : glyph;
}

* next line; neither is the chapter's attribute any more, since something now stands between it and
* the `\c`. So `\c 1 ^\n\ca 2\ca*` becomes `\c 1\n^\ca 2\ca*`, and `\c 1 ^\n\cp 2` becomes
* `\c 1\n^\n\cp 2`. With the caret past the `\ca` run, the content goes after that run instead, as
* typing there puts it, and the run stays the chapter's: `\c 1 \ca 2\ca*^` becomes
* `\c 1 \ca 2\ca*\n^`. Rather than build that shape by hand, the chapter's bytes are re-tokenized
* with one character standing in for the content (`$rebuildChapter`, the same settle that typing

Approving.

🤖 Generated with Claude Code

tjcouch-sil and others added 4 commits October 12, 2026 08:02
With the caret at the end of a chapter's `\cp` run, insertMarker put a
note, character marker or verse BEFORE the `\cp`, which detached the
published number. Paratext 9 puts the marker's bytes at the caret
(`\cp B\f ...\f*`), and ParatextData reads that back as the chapter keeping
its pubnumber with the inserted content after it (probed through
GetChapterUsx/SetChapterUsx; that shape is also a round-trip fixed point).

A caret anywhere in the `\cp` run now puts the content on a new markerless
line right after the chapter line, without re-tokenizing the chapter: a
stand-in after the run would be read as more of the published number.

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

Deleting part of a chapter's `\c N` glyph (`\ 2` once the `c` is gone,
`c 2` once the backslash is), typing another marker over the `\c`, or
typing text ahead of it left bytes that no longer tokenize as a chapter.
The chapter settle refused them and kept them as a pending literal while
serialization fell back to the stored number: the file kept `\c N`, the
screen kept the damage indefinitely, and anything typed there was never
saved.

The chapter settle now replaces the chapter's region with what its bytes
tokenize to, in both the live settle and the read-only one `getUsj()`
reads, as Paratext 9 keeps the bytes it holds. Text ahead of any marker
stands markerless rather than in the `\p` the tokenizer opens for it, and
the glyph's trailing separator is dropped. The host's save repair then
puts back a marker the chapter needs above that text, as Paratext 9 does.
An emptied line waits for the caret to leave before the live settle
removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bare backslash in USFM (`\ 2`) is a marker whose name is empty:
ParatextData reads it as `<para style="">` and writes that back as `\ 2`,
a fixed point, and Paratext 9 shows and saves the bytes as they are. Both
converters tested `style`/`marker` for truthiness, so the empty name was
dropped: the paragraph reached the editor with no marker, which shows it
as `\p`, and the next save wrote `\p 2` over the author's `\ 2`.

Both directions now test for presence, as setAttributes already does for
every other attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`\ 2` left on a chapter line once the `c` is deleted settled as plain
text, while ParatextData reads the same bytes as a paragraph with no
marker name. The saved chapter then read back as a different shape than
the editor sent, and the editor reloaded it once the user went idle. The
chapter settle now produces that nameless paragraph itself, so the save
reads back as sent.

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

Copy link
Copy Markdown
Member Author

🤖 Claude: Round 3 addressed in 9a60a0bb, plus three more commits below.

\cp sibling of item 2: not intended — fixed. With the caret anywhere in the chapter's \cp run, insertMarker (note, character marker, verse, milestone, and the marker menu) now puts the content on a markerless line after the chapter line, and the chapter keeps its published number: [chapter 2 (pubnumber "B"), inserted, …]. That is also what Paratext does. I put each marker's bytes at that caret in USFM (\c 1 \cp A\f + \fr 1:1 \ft\f*, likewise \nd x\nd* and \v 1) and read it back through GetChapterUsx. ParatextData folds \cp A into pubnumber and puts the inserted item after the chapter. Writing that USX back through SetChapterUsx returns it unchanged.

It doesn't go through the stand-in/re-tokenize path: a stand-in after a \cp run is read as more of the published number, since \cp closes only at the next block. Nothing on the chapter line changes, so the caret just goes into a new markerless line after the chapter's attribute region. Typing at that caret still extends the published number ("BX"), as the bytes do in Paratext 9.

New tests: a note, a character marker and a verse inserted from the end of a \cp run, with and without a \ca before it (six cases). All six fail on 46eb98f3. packages/platform suite at 0f949073: 2,174 passed, packages/utilities 60; typecheck and lint clean; committed build output verifies.

Also in this round (90604b3f, d64c4933, 0f949073, found while looking into the core PR's e2e note): bytes left on a chapter line that stop being a chapter marker (\ 2 after backspacing the c, another marker typed over the \c, text typed ahead of it) used to stay on screen unsaved while the file kept \c 2. The chapter settle now replaces the chapter with what those bytes tokenize to, in both the live settle and the read-only one, as Paratext 9 keeps the bytes it holds. paranext-core#2816 then restores the marker above them. The test that pinned the old refusal now pins the paragraph \q1 1 becomes. A bare \ is a marker with no name; the converters used to drop the empty name, so \ 2 loaded as \p 2 and saved that way. It now loads and saves as itself (nameless-marker.test.ts), and the chapter settle makes the same nameless paragraph.

🤖 Generated with Claude Code

…rbs-text

# Conflicts:
#	packages/platform/dist/index.js
#	packages/platform/dist/index.js.map
#	packages/platform/src/editor/markerEdit/virtualSettle.utils.ts
#	packages/platform/src/editor/markerMenu/markerMenuApply.utils.ts
#	packages/platform/src/editor/markerMenu/markerMenuContext.utils.ts
@rolfheij-sil

Copy link
Copy Markdown
Contributor

🤖 Claude: Code review (round 4)

Thanks — round 4 checks out at bfd98c5b. The \cp insertions keep the chapter's published number (our earlier repro now gives [chapter 2 (pubnumber "B"), inserted, …]), a chapter line whose bytes stop being a marker is saved as what it is, and the nameless marker round-trips through the converters. The packages/platform suite passes here (2450 passed, 3 skipped), as does packages/utilities (60 passed). With this build staged into paranext-core#2816, all seven chapter-* isolated e2e specs pass. The merge with main keeps both sides in markerMenuApply.utils.ts, markerMenuContext.utils.ts and virtualSettle.utils.ts.

No new issues found.

🤖 Generated with Claude Code

USFM puts markerless text standing behind a line break into the paragraph
before it, so `\toc1 stuff` / `x\c 1` saves and reloads as `\toc1 stuff x`,
as Paratext 9 keeps it. The chapter settle left that text on a markerless
line of its own instead, which then changed shape on reload.

The live settle moves the line's nodes onto the end of the preceding
paragraph (keeping a caret restored into them), and the read-only settle
mirrors it, so getUsj() agrees with the settled document. A space is put
between them only where the line break would have read as one.

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

Copy link
Copy Markdown
Contributor

🤖 Claude: Code review (round 5)

5bd302c checks out: text typed ahead of a chapter marker joins the paragraph before it in both the live and the read-only settle, and a healthy chapter line, a \ca/\cp region and a nameless \ 2 line are left as they were. The packages/platform suite passes here (2459 passed, 3 skipped), and paranext-core#2816's extension tests and typecheck pass with this build staged.

No new issues found.

🤖 Generated with Claude Code

tjcouch-sil and others added 11 commits October 19, 2026 16:58
…rbs-text

# Conflicts:
#	packages/platform/dist/index.js
#	packages/platform/dist/index.js.map
#	packages/platform/src/editor/markerEdit/tier2Rebuild.utils.ts
#	packages/platform/src/editor/markerEdit/virtualSettle.utils.ts
#	packages/platform/src/editor/markerEdit/whitespaceDisplay.plugin.utils.test.tsx
#	packages/utilities/dist/index.cjs.map
#	packages/utilities/dist/index.js.map
#	packages/utilities/src/converters/usj/nameless-marker.test.ts
#	packages/utilities/src/converters/usj/usj-to-usx.ts
#	packages/utilities/src/converters/usj/usx-to-usj.ts
… is no longer a chapter

Once a chapter line's bytes stop reading as a chapter marker (`\ 2` after
its `c` is deleted, `\sc 2` after an `s` is typed into it), the line
settles as what it now is. getSelection() reported a caret anywhere on
that line as the start of the line, because the settled side of a
chapter scope was built only when it still held a chapter. Build it as
any other settled root content when it does not, so the caret keeps its
exact place: `$.content[0]['marker']` just past the backslash, or the
offset in the line's text.

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

A chapter line can settle into more than its chapter: text typed ahead of
the marker becomes a line of its own (or joins the paragraph before the
chapter), and bytes that stop reading as a chapter marker settle as what
they now are. Three readers of that output assumed the chapter came
first and alone:

- the live and read-only settles carried annotation marks only when the
  first rebuilt node was the chapter, so a mark beside the chapter was
  dropped when text was typed ahead of it or the line became `\ 2`;
- the position plan did not know a leading line joins the paragraph
  before the chapter, so while such an edit was pending every top-level
  index from there on was reported, and resolved, one item off.

$settledChapterScopeFragment reads the settled nodes the one way all
three now use, and the chapter's position plan takes in the paragraph a
leading line joins.

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

The browser moves the caret itself for most keys and tells Lexical only
through `selectionchange`, which it fires asynchronously. A key pressed
before that event is handled acts on where the caret was: Home then
ArrowRight on a chapter line jumped to the next paragraph, and End,
three ArrowLefts and Backspace deleted the line's trailing space instead
of the `c`. Most visible on the first keys after a chapter loads.

KeyDownCaretSyncPlugin, first among the critical-priority key handlers,
replaces a collapsed text caret with the DOM's when the DOM's sits at a
different text position. A caret at the DOM position Lexical renders is
kept, so a position the editor chose among several at one DOM point is
left alone.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s on an element

After Home the browser can hold its caret on the element at the boundary
before the text rather than in the text node, and the caret sync left
that alone, so ArrowRight could still act on Lexical's stale end-of-line
caret. The sync now reads the DOM's caret back as a Lexical position
wherever it is, and replaces Lexical's only when that position differs.

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

# Conflicts:
#	libs/shared-react/src/plugins/usj/index.ts
#	packages/platform/dist/index.js
#	packages/platform/dist/index.js.map

@katherinejensen00 katherinejensen00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving the PR. There is two small test fixes, but not worth holding the merge up over.

[Tests] caret-restore.util.test.ts (~L257)

8f9f4cc1a2 deleted the scheduler-level test "accepts a paragraph-start placement the editor reads back as the marker location". That test pinned the fix for the 2 s snap-back. isSamePlaceInDocument now covers only the end-of-text / next-marker alias. If the editor ever reads a caret at the start of a paragraph's text back as the paragraph's own location, the snap-back returns and no test fails. Question for the author: does that alias still exist? If it does, add a comparator case. If it does not, say so where the test was.

[Tests] chapter-marker-repair.util.test.ts (~L326)

No test passes a caret any more when the whole chapter marker was deleted. The deleted glyph-specific test also pinned something that is not glyph-specific: a caret in the line that now stands where the deleted marker stood is not treated as a moved chapter line. The guard is isMarkerDeleted || storedChapterIndex < 0 ? undefined : … (~L693). Drop it, and every test still passes. Suggested replacement: nameless('2 more') with the caret at $.content[0].content[0] offset 2, expecting the restored-marker caret, not $.content[1]….

@katherinejensen00 reviewed 32 files and all commit messages, and made 1 comment.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on tjcouch-sil).

@tjcouch-sil

tjcouch-sil commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

🤖 Claude: Both test findings are addressed in paranext/paranext-core#2816 (7271b7b5f9a, 26e934112ba), since the two files are on that side.

caret-restore.util.test.ts. The alias no longer exists. I set the caret at the start of a paragraph's text through the real Editor and read it back, in all five views. Every view reported the same text start, so I didn't add a comparator case for it.

The same check found a different read-back that did matter. A view that hides markers can't show a caret on a chapter number. So a caret placed just past one (the target after a correction) goes to the start of the next paragraph, and the editor reports it there. The restore treated that as a placement that hadn't taken and placed it again every 50 ms for two seconds.

In the Formatted view, Backspace at the start of a chapter deletes its marker, and that triggers the repair. A caret the user moved straight afterwards was pulled back to the start of the chapter, and the log then said the chapter had not loaded in time. The restore's read-back check now also takes a caret at the start of the line right after a target at the end of a line. That is a different USFM position, so it is a placement check, not a location equality. It is renamed isCaretPlacedAt (26e934112ba), replacing isSamePlaceInDocument, which only the restore used, and it accepts that case in one direction only. Tests cover it, plus the cases it must reject: a caret before the target, a caret past a marker, and a caret a space away on the same line. The new chapter-marker-formatted-caret.spec.ts reproduces it in the app: it fails without the change and passes with it.

chapter-marker-repair.util.test.ts. I added a caret to the existing "only the marker was deleted" case ($.content[0].content[0], offset 2, in the line that moved up into the marker's place). It expects the restored-marker caret. With the isMarkerDeleted guard dropped, it now fails and gets $.content[1].content[0].

Gates: typecheck and lint are clean, and the editor extension's tests pass (4824). All eight chapter-* isolated specs pass on the first attempt with this PR staged at fbd8dd86. Nothing changed in this PR.

🤖 Generated with Claude Code

@tjcouch-sil
tjcouch-sil merged commit 7bd11cf into main Oct 23, 2026
6 checks passed
@tjcouch-sil
tjcouch-sil deleted the fix-chapter-line-absorbs-text branch October 23, 2026 18:57
tjcouch-sil added a commit to paranext/paranext-core that referenced this pull request Oct 23, 2026
* PT-4608: Add the chapter-marker repair design and implementation plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Port PT9's FixChapterNumbers to USJ as a save-path repair

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Strip nested chapter markers before the anchor logic runs

A chapter object nested inside a paragraph is never legal USJ, and it reaches
the writer as a `\c` regardless of where it sits. Stripping it only on the paths
that go on to place a top-level marker left it in place for an intro-only
chapter 1, which draws the same ValidateChapterNumber rejection — and the same
poisoned save loop — this module exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Repair chapter markers before the PDP write and tell the user

Paratext refuses to write a chapter document whose `\c` marker disagrees with
the chapter being written, and the rejection leaves the bad marker in the
editor, so every later save of that chapter is rejected too. Run the repair on
the way out of the editor, push the repaired document back into the editor when
the save still targets the chapter on screen, and warn the user under a stable
notification id so the repeated saves of a long edit update one toast.

The chapter is taken from the selector this save closure captured, not read
live: a pending trailing save can fire after the user has navigated away, and
the repair has to be computed against the chapter the content was typed in
while the editor push-back is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Guard the repair push-back and route its toast to this editor

`setEditorUsj` sets the editor's USJ and clears annotation info, and this file
already treats that pair as able to throw — the failed-save revert wraps the
identical two lines in try/catch. The repair path is the more exposed of the
two, being the only caller that feeds the editor a synthesized document rather
than one sourced from the PDP or read back out of the editor. An exception
there skipped the PDP write that un-poisons the chapter, so the next save
repaired the same document and failed the same way.

The notification also carries `webViewId` now. Sharing a `notificationId`
without it opens a second toast whenever an update routes to a different
window, and the save that raises this one can come from the window-blur flush —
exactly when this window is no longer focused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Put the chapter-repair push-back decision under test

The decision of whether a repaired chapter document is pushed back into
the editor lived inline in `saveUsjToPdpIfUpdatedInternal`, which nothing
can reach from a test. Lift it into `applyChapterSavePreparation` in the
repair util, where the four behaviours it owns can be pinned: the
push-back applies when the save still targets the chapter on screen, is
skipped when the user has navigated away, moves the sent-to-PDP baseline
and the editor document together, and reports the correction to the user
in both cases.

Also drive the "leaves a correct chapter alone" coverage through real USX
parsed by `usxStringToUsj`, in the shapes the data provider serves
(chapter 1 with its `\id` line and headers, a later chapter, a chapter
carrying `altnumber`/`pubnumber`). A false `didRepair` on an untouched
document would toast the user on every save of an ordinary typing
session, and the hand-built fixtures are too small to catch that.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Tell the user when the backend refuses a save

The editor saved the chapter being edited every time the user stopped
typing, and reported a rejection only for the two message shapes it
recognized — a Send/Receive edit block and a permissions failure. Every
other rejection went to the log alone, so a chapter whose saves were all
being refused looked, on screen, exactly like a chapter that was saving
fine.

Classify the rejection instead, in a new `save-failure-report.util`, and
give an unrecognized one its own generic notification naming the project.
The backend's own wording is written for a developer, so it stays in the
`logger.error` call and never reaches the toast. Only the two recognized
kinds still revert the editor to the last PDP state: nothing here knows
the backend would accept that document in place of an unrecognized
rejection, and the editor holds the user's only copy of the edit.

The same module owns the repeat policy: a chapter the backend keeps
refusing is refused on every save, so a report fires only when the KIND
of rejection changes, and a write that runs without throwing clears the
memory and dismisses the toast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Decide the save-failure revert before awaiting the toast

The revert of a rejected save ran after `await`ing the notification, which
is a cross-process call that yields across macrotask boundaries. That is
long enough for `useEditorPdpSync`'s effect to apply a newer PDP delivery
into the editor, which the revert then overwrote with the older captured
snapshot — recording the older document as last-sent and suppressing
re-convergence until the next delivery. The hook defers only while the
editor is focused with a recent local edit, so this bit exactly where a
cross-chapter rejection fires: a chapter-switch flush or a trailing save.

`planSaveFailureResponse` now works out the kind, whether to report, and
whether to revert in one synchronous call, so the editor's content is put
back before anything is awaited. `shouldRevert` is deliberately
independent of `shouldReport`: the deduplication gates the toast across a
run of identical rejections and must never gate the revert.

Also give the generic save-failed toast `duration: 0`. Auto-close is
governed solely by `duration`, and an omitted one computes 10-35 seconds
from the message length — so the one toast a failing run produces closed
itself and left the chapter silently failing to save, which is the
condition this notice exists to make visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Correct chapter markers in the C# chapter-write backstop

`ScrText.ValidateChapterNumber` refuses a chapter write whose `\c` marker
disagrees with the chapter being written — a wrong number, a second marker
typed mid-chapter, and a deleted marker are all hard rejections — and the
rejection does not repair whatever produced the bad marker, so every later
write of that chapter fails too.

The Scripture editor now corrects its own document before sending it and
tells the user, so this is the backstop for every other writer: an
extension or any non-editor caller that writes chapter USFM or USX gets
the same correction, silently apart from a log line.

`ChapterMarkerCorrection.FixChapterMarkers` ports PT9's
`UsfmEditorTextLoader.FixChapterNumbers`, keeping its handling of the two
shapes chapter 1 alone allows (an introduction ahead of the marker, and an
introduction-only chapter with no marker at all) and its habit of leaving
USFM that needs no correction byte-identical. PT9's `Debug.Assert` on a
zero chapter number becomes a plain return: a book-level write
legitimately carries a marker per chapter, so only `SetChapterUsfm` and
`SetChapterUsx` are wired to it. PT9's `errorMessage` is dropped; the
renderer owns the user-facing message.

In `SetChapterUsx` the correction runs before the comparison against what
is on disk, so that comparison sees what would actually be written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Pin the chapter setters to the chapter-marker correction

The correction's own table covers the transformation, but nothing pinned
the two setters to actually running it, which is the whole of what the
backstop does for callers. These go through the real save path, and the
first test pins the premise that makes the other two falsifiable: Paratext
throws `ChapterizationException` for exactly the USFM they hand the
setters, so a setter that stopped correcting would fail rather than
silently pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Close the chapter-marker correction review round

Port fidelity to PT9's table is what this correction rests on, and three
of that table's line-ending rows were unpinned: the mixed-ending no-change
path, and the two rows that put Unix endings through the introduction walk
and the marker strip loop, where `Unix2Dos` is what keeps the match
indexes pointing at the right bytes.

In `SetChapterUsx` the correction log now sits after the
unchanged-content short-circuit, so "corrected before saving" is only
written when a save follows. The message itself moves next to the other
private helpers, shared by both setters, so a reworded log cannot diverge
between them.

The USX wiring test also edits a verse, not just the chapter number: the
chapter it seeds already read "two", so asserting that text could not tell
a corrected write from no write at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Put a restored later-chapter marker at the very start

`findChapterStartIndex` placed a restored chapter marker behind a leading
`\id` book node for chapters after the first. `ScrText.ValidateChapterNumber`
rejects `verseRef.ChapterNum != 1 && parts[0] != ""` with "Text present before
chapter marker.", so that shape is one ParatextData refuses — the repair could
emit a document the writer will not take. Paratext 9's `FixChapterNumbers` and
the C# `ChapterMarkerCorrection` sibling both prefix the marker at position 0
unconditionally; match them. The stray book node is left in place, as Paratext 9
leaves it. Chapter 1's placement rule is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Record the chapter-marker save-boundary repair decision

Adds `adr-chapter-marker-repair-at-the-save-boundary` in byte-order slug
position: repair the chapter document on the way out in both write layers
rather than preventing the edit, leave book-level writes alone, and put a
restored later-chapter marker at index 0 because ParatextData refuses any
content ahead of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Promote save-boundary repair into the patterns standard

The decision log keeps the why; the standards keep the current rule. Adds a
cross-cutting section stating the rule a future implementer needs: repair
content the ParatextData writer refuses at the save boundary rather than
policing the edit, and do it in every layer that can originate the write,
because a correction only the backend makes is invisible to a focused editor
and loses to `EDITOR_OWNERSHIP_WINDOW_MS` deferral. States the chapter-marker
position-0 invariant both ports must hold, and cross-references
`adr-chapter-marker-repair-at-the-save-boundary`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Correct the ADR's cited mechanisms and pin the degenerate case

Book-level writes are safe because `SetBookUsfm`/`SetBookUsx` never call the
corrector, not because of the `chapterNum <= 0` guard inside it; say so, and
name both ports' symmetric early returns as the defensive guards they are.
Qualify Paratext 9's user-facing behavior: it reloads with `Revert()` on any
repair but reports only the mid-chapter-marker case, so notifying on every
repair is ours. Label the Paratext 9 repo paths `in PT9 ...` per the house
convention so they do not read as paranext-core paths.

Adds a test for a later-chapter document holding nothing but book nodes, the
degenerate branch of the placement rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1nNQqhhvS7igB7UDMqVH2

* PT-4608: Cover the chapter-marker repair end to end

The unit suites pin both ports of the repair and the save-path plumbing, but
neither can reach the two things that decide whether the fix works: that the
Standard-view gesture really does poison the document, and that the shape the
correction produces is one real Paratext will accept.

chapter-marker-repair.spec.ts drives the gesture in the running app and asserts
the marker is put back, the correction notice is raised, the keystrokes that
follow the push-back still reach the document, the chapter goes on saving across
a chapter switch, and a repair carried by the chapter-switch flush reports while
leaving the chapter navigated to holding its own content. Stubbing
repairChapterMarkers to never repair makes it fail on data-number="15", so the
assertions are load-bearing rather than incidental.

chapter-marker-backend-correction.spec.ts writes wrong-number, extra-marker and
deleted-marker chapters straight to the Paratext PDP over PAPI, with no UI. It is
the only test that puts ChapterMarkerCorrection's output in front of
ScrText.ValidateChapterNumber, which is the code that refuses these documents in
the first place.

The correction notice is watched with a MutationObserver installed before the
gesture rather than a locator assertion after it: the notice carries no
duration: 0, so it closes itself on a length-derived timer.

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

* PT-4608: Name the chapter the correction notice is about

The notice said only which project a chapter marker had been corrected in.
That reads fine when the repair happens under the user's cursor, but it is not
always what happens: a save carried by the chapter-switch flush repairs the
chapter the user has just left, so the notice arrives while a different chapter
is on screen and silently invites the reader to blame that one.

Name the book and chapter instead, captured in the save closure alongside the
chapter selector so a flush-carried save reports the chapter its content was
typed in rather than the chapter now displayed. The book name is localized
through %Book.<id>%, which the localization service backfills with English names
for every canonical id, falling back to the three-letter id while the lookup is
still unresolved.

The e2e spec now matches on the whole sentence including "Jonah 1", so the flush
step fails if the notice ever goes back to naming whatever is on screen.

Also corrects a comment claiming the repair runs ~100ms after the keystroke. It
runs on the ordinary 700ms trailing save; measured in-page, keystroke to
push-back is 766ms. The earlier figure came from reading nominal loop labels in a
probe as elapsed time.

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

* PT-4608: Put the caret back where the chapter marker was corrected

Hand QA found the correction leaves the user with no insertion point. The
push-back is EditorRef.setUsj, and the editor loads external content through
LoadStatePlugin, which parses a fresh editor state, sets it, and dispatches
CLEAR_HISTORY_COMMAND. Every Lexical node key regenerates and the parsed state
carries no selection, so the caret the correction was made under disappears
mid-typing.

The repair now reports where the caret belongs in the coordinates of the
document it produced: just past the corrected or restored chapter number,
failing that the boundary a removed marker occupied, failing that the surviving
marker. The save path applies it once the editor has loaded that document, and
only when the editor held DOM focus at push-back time, so a caret is never
pulled out of a footnote popover that legitimately has it.

The target is computed from the repair rather than carried across from the
pre-repair selection on purpose. EditorRef.getSelection addresses the live tree
while the repair works on getUsj()'s settled document, and the two differ for
exactly the gesture that matters: a \c typed under the caret is still a pending
literal in the live tree while the settled document already holds a chapter
node. A captured path would be in the wrong coordinate system.

The offset encodes the editor's glyph byte layout (\c, NBSP, number, space),
which nothing in this repo can check, so the e2e spec reads the web view's own
DOM selection and asserts exactly one glyph character follows the caret.
Disabling the restore makes that step fail with charsAfterCaret -1, so it is
load-bearing. Its neighbouring step now clicks into the body before typing,
since the restored caret sits in the marker, where characters are marker bytes.

Undo and redo history is still emptied by every repair; fixing that needs the
mutation to move into the editor as node surgery, which is a scripture-editors
change. Both halves are recorded in the decision log as
adr-chapter-marker-repair-replaces-the-document, with why delta ops are not the
lever.

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

* PT-4608: Record that a repair clears every annotation too

The push-back's reload does not only drop the caret and the undo stack. A load
replaces the node map, and the TypedMarkNodes that carry annotations live only
in that tree, never in USJ, so every annotation goes with it. That is not new to
the repair — it holds for every setEditorUsj call, the ordinary PDP echo
included — and the local bookkeeping already follows it, since setEditorUsj runs
clearAnnotationInfo.

What no path announces is the clearing itself. A per-annotation removal sends
that annotation's interactionCommand with a TypedMarkRemovalCause, but a
wholesale clear fires no onRemove at all, and the editor exposes no event for it
either. Reusing 'destroyed' would be false: the published contract defines it as
"when the text the annotation was on is completely deleted", so a consumer
acting on it could discard data whose text is intact. An honest announcement
needs a new AnnotationAction value, which is an extension-facing API decision
spanning every reload path rather than this one, so it is recorded rather than
made here.

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

* PT-4608: Only repair a chapter document the user has actually changed

Review pass over the branch turned up two live defects and a set of smaller
ones.

The repair fired on documents nobody had touched. useEditorPdpSync calls
saveUsjToPdpIfUpdated on any delivery that matches the last-sent baseline, no
local edit required, and a legitimately blank chapter carries no \c node at all
— the state isChapterBlank and EmptyChapterView are built around. So for every
chapter after the first, opening a blank one synthesized a bare \c, pushed it
into the editor, wrote it to the project and told the user their chapter marker
had been incorrect. That also flipped isChapterBlank false, taking away the
versification-aware scaffold the "Add chapter number" button would have built.
prepareUsjForChapterSave now returns early when the editor's document still
equals what the PDP holds: a document that has not moved is the PDP's own
content on display, and correcting stored content is the C# backstop's job, not
a view's. The gate reads the pre-repair document, so the deliberate
repair-before-compare ordering is unchanged and a repair that lands back on the
PDP's content still reports itself.

The notification ids were module-level, so every open editor shared them.
papi.notifications.dismiss fans out to every ready window, so one editor's
recovered save took down another editor's still-standing "could not be saved"
notice while that editor went on failing with its report already spent; and two
editors correcting a marker overwrote each other's notice instead of raising
two. Both ids now come from the web view id.

The permissions arm had inherited the new report gate while keeping the default
auto-close, which made a persistent permissions failure silent after its first
toast. It now shares the unknown arm's persistent, routed notice, differing only
in message. Sync-blocked stays transient because SyncBlockedBanner holds that
state in view.

Also: Spanish for the two new strings, with the en-only test blocks replaced by
parity and per-locale placeholder assertions; a C# case pinning that \ca/\cl/\cp
survive a correction; XML doc on both chapter setters saying they rewrite what
they are handed; an unmount cleanup for the caret-restore timeout;
deepEqualAcrossIframes and isLocalizeKey in place of hand-rolled equivalents;
and a comment pinning CorrectFirstChapter's offset to Paratext 9's own, which a
reviewer had read as an off-by-two — the PT9 source uses the identical
expression, and the "simpler" reading changes the answer for `\c 1\ip`.

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

* PT-4608: Put the save-failure report gate under test

The gate that turns a run of identical backend rejections into one report lived
half in a pure module and half in the web view: the module decided, and the
component kept the memory, set it on report, and cleared it plus dismissed the
notice on the next write that completed. Only the deciding half had tests, and
the half without them is the one that decides whether a user ever learns their
chapter stopped saving — the notice is raised once per run and stays up under a
stable id, so a clear that goes missing leaves a stale toast on screen AND
leaves the next rejection of the same kind unreported.

Give the memory to the module that owns the gate. planSaveFailureResponse now
takes the memory and records what it says to report; clearOutstandingSaveFailure
performs the other transition and reports whether there is a notice to take
down, so the web view never dismisses an id it never sent. Neither transition
can drift from the other now, because neither is written anywhere else.

Six cases cover the run: one report per run of identical rejections, the kind
actually recorded, the revert continuing on every occurrence while the report
stays suppressed, and the completed write both re-arming the report and asking
for the dismissal. Falsified — stubbing clearOutstandingSaveFailure to a
constant false turns the two lifecycle cases red and leaves the other sixteen
green.

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

* PT-4608: Say what the correction did without passing judgment

"The chapter marker in Jonah 1 was incorrect and was automatically corrected"
reads as a verdict on the user for a marker they were in the middle of typing.
It now says what actually happened: it did not match the chapter, so it was
corrected. Both locales, and the end-to-end spec matches on the sentence.

Correct the ADR while the wording is in hand. It claimed the caret landing
inside the marker glyph was "bounded by the notice that explains it", and the
notice explains nothing of the sort — it does not mention the caret, the emptied
undo stack, or the cleared annotations. Keeping it to one sentence is the right
call, but the losses are then unmitigated rather than explained, and the log
should say so.

Pin why the end-to-end caret cover transfers
--------------------------------------------

The caret target addresses the chapter marker's first content item — the glyph
the editor renders — and the review asked whether that resolves against a marker
the repair SYNTHESIZES, which has no content array, when the only end-to-end
cover is of one it RENUMBERS. It does, and for a reason worth pinning rather
than rediscovering: the parser emits a chapter node childless too, so the
renumbered marker is no different. A test now holds that, because it is a
property of the parser that nothing else here would notice changing.

An end-to-end step for the deleted-marker gesture was written, run, and dropped:
deleting the glyph one byte at a time does momentarily empty the chapter node,
but the editor puts one back on its own, so the save path renumbers rather than
restores. With the restore branch stubbed out to do nothing, the step still
passed — as did a caret assertion with the restore branch's caret target forced
to undefined, since by that point in the run the caret sits one character from
the end of a chapter glyph regardless. Two sabotages, two green runs; a test
that cannot fail is worse than no test. The renderer's restore branch appears to
be reachable only by writers that are not the editor, which is already covered
by chapter-marker-backend-correction.spec.ts.

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

* PT-4608: Cite the chapter-marker guards by name, not line number

The ADR's line citation into chapter-marker-repair.util.ts had already
drifted (138 vs 223) and the file is still moving; naming the guarded
functions keeps the reference valid.

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

* PT-4608: Keep the caret in the text when a chapter marker is restored

Typing into a chapter with no `\c` at all -- in Power mode, any chapter
the book has not reached yet, which shows as the ordinary empty editor --
reaches the repair's restore branch: the marker is added, the document is
pushed back into the editor, and the notice is raised. The caret then went
to just past the new chapter number, so it jumped out of the text being
typed into the marker glyph. Whatever was typed next became part of the
chapter number (`2QQNEXT`) and the following repair renumbered it away,
silently.

A restore now reports CARET_AT_DOCUMENT_END instead of a position, and the
web view carries it out by focusing the editor with no selection, which
Lexical resolves to the end of the root -- where the user was typing. A
renumber keeps its caret just past the corrected number. The notice is
unchanged.

The new isolated e2e, chapter-marker-restore.spec.ts, is the first test
that reaches the restore branch through the UI: it cuts Jonah back to
chapter 1 with a book write (which the correction never touches), types
into the blank Jonah 2, and asserts the marker, the notice, the caret at
the end of the typed text, that the next keystrokes continue that text,
and the saved USFM. Put back to the old caret target, it fails on the
caret step.

The toast recorder both specs use moves to e2e-tests/fixtures. The unit
test that argued the renumber e2e also covered the restore caret is gone,
since the restore no longer addresses the marker glyph and has its own e2e.

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

* PT-4608: Never save the chapter being left over the chapter being loaded

After navigation the editor goes on showing the chapter it is leaving,
still editable, until the new chapter's content arrives, while every save
scheduled in that window is already bound to the new chapter. A keystroke
typed there was written to the new chapter when its content arrived (the
replace path flushes the pending save first), or when the debounce fired.
Paratext used to refuse that write as a wrong chapter number; with the
chapter-marker repair, the marker was renumbered to match and the old
chapter's text replaced the new chapter's. Leaving a blank chapter did the
same with almost nothing, because the repair restores the missing marker.

The editor's document identity, which useEditorPdpSync already tracked,
is now passed out to the web view, and a save whose content is not from
the chapter it writes to is dropped (isEditorContentForChapter). The
replace path moves that identity only after the flush, so the flushed
save still sees the chapter the editor is holding. Keystrokes typed in
that window are not written anywhere: they came after the user asked to
leave the chapter, and the new chapter replaces them on screen.

The decision record and the repair pattern now say what else the
chapter-number refusal was catching.

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

* PT-4608: Record that deleting a chapter marker is a restore too

The restore caret goes to the end of the document on the reasoning that a
restore means the user was typing into a chapter with no marker. Deleting
the marker from a chapter that has text reaches the same branch -- the
editor removes a chapter node whose glyph is emptied -- and there the end
is not where the user was. The doc comment and the decision record now
say so, and why the end is still the choice: text typed there is kept,
where text typed into the new marker is corrected away.

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

* Revert "PT-4608: Record that deleting a chapter marker is a restore too"

This reverts commit 4395655e777. The note was inferred from the editor's
chapter transform (an emptied chapter node removes itself), but no
deletion gesture probed in the running app reaches the restore branch:

- Backspacing the glyph away: the editor puts a chapter node back, and
  the save renumbers it (caret goes to the marker, as for any renumber).
- Selecting from the chapter line into verse 1 and pressing Delete,
  Ctrl+Shift+Home then typing, and select-all then typing: the chapter
  node survives each time, absorbing the merged or typed text into its
  glyph and keeping its number, so no repair runs at all.

So a restore is reached by typing into a chapter with no marker, as the
doc comment and the decision record already said.

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

* PT-4608: Don't replace the document under a user who is still typing

A save does not only run once typing pauses: the PDP sync saves as soon
as a delivery arrives, which can be mid-typing. If that save repaired a
chapter marker, the push-back replaced the whole document under the user,
and the editor has no caret until the repair's caret restore runs, so the
keys typed in between landed wherever the browser left them -- at the end
of the chapter, deleting or adding text there.

The push-back now waits until typing has paused, read from the same
last-local-edit stamp that schedules the debounced save. The repair is
still written and still reported; the debounced save that follows the
pause makes the same repair and puts it on screen.

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

* PT-4608: Put the caret back on the chapter line when its marker is deleted

Backspacing a later chapter's whole marker faster than the save debounce
removes the chapter, and the save restores it. A restore sent the caret
to the end of the document, which is right for the other way into it --
typing into a blank chapter, where the user was typing at the end -- but
here the user was working on the chapter line, and the caret jumped to
the end of the chapter.

When the only difference from the stored chapter is the missing marker,
the restore now puts the caret just past the restored number instead.
Typing into a blank chapter, or any change beyond the marker, keeps the
caret at the end of the text.

This also puts back the note that deleting a marker reaches the restore,
which 7c07706ac80 reverted on a probe that could not reach it: the
chapter only disappears when the whole marker goes before a save runs.

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

* PT-4608: Cover the caret after correcting a marker still being edited

Backspacing a later chapter's number a key at a time, pausing after
each, sent the caret to the end of the chapter after every correction,
and the next keys deleted the chapter's last characters, with nothing on
screen to say so. The number was still a pending edit in the editor when
the repair put it back, and the editor's setUsj measured the corrected
document against node state -- which still held the number -- so it
skipped the load. The numberless glyph stayed, the caret target just
past the number had nothing to resolve against, and the caret stayed
where focus() had put it.

The fix is in scripture-editors (setUsj now compares against the settled
document), so a repair always loads. This records that dependency in the
decision log, says why the caret restore places its caret even when the
editor already reports a selection (a load leaves a stray browser
selection where the replaced text was -- in verse 1's number, where the
next Backspace deletes it), and adds chapter-marker-deleted.spec.ts:
backspace the whole marker away quickly (a restore), then the number one
key at a time with a save between each (a renumber), asserting each time
that the marker comes back with the caret just past the number, and that
the saved chapter keeps all of its text.

Needs the scripture-editors change staged: platform-yalc must move past
it before this merges.

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

* PT-4608: Don't replace the document under an open palette or note editor

The chapter-marker repair's push-back replaces the whole document, which
regenerates every node. Typing in a marker palette or a footnote popover
never stamps the main editor's last-local-edit time, so a debounced save
firing while one was open pushed the repair back under it: the popover's
own save could no longer find its note, and a palette pick could land at
a restored selection whose path had shifted (a removed mid-chapter \c).

The push-back is now also skipped while a session is open, using the same
live/stale session check as the PDP sync (without its side effect of
closing a stale note session). The repair is still written and reported;
the next save after the session closes puts it on screen. The parameter
is renamed isUserTyping -> isUserEditing to cover both cases.

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

* PT-4608: Leave a correct chapter marker with its \ca on its line alone

The C# backstop treated a chapter marker as correct only when it stood on
a line of its own (\c N and a line break), as Paratext 9 does. A correctly
numbered marker whose line also carries its alternate number
(\c 2 \ca 3\ca*) was "corrected" by moving the rest of the line onto the
next line: a whitespace-only change that was logged as a correction and
rewrote the chapter on disk the first time it was saved.

Paratext's own ValidateChapterNumber reads the number only up to the
first non-word character, so that line was never refused. A marker whose
number matches the chapter and is ended by whitespace or the end of the
text is now left as it is. A wrong or partial number (\c 23 for chapter
2) is still corrected. This is a deliberate departure from Paratext 9,
recorded in adr-chapter-marker-repair-at-the-save-boundary.

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

* PT-4608: Keep Paratext 9's \c/\ca layout but don't report it as a correction

A correctly numbered chapter marker whose line also carries its alternate
number (\c 2 \ca 3\ca*) is written again as Paratext 9 writes it: \c 2 on
its own line with the rest of the line below it. Files keep the shape
Paratext 9 gives them, so a chapter doesn't flip between the two layouts
in its revision history depending on which program saved it last.

That layout is no longer reported as a correction, though. The marker was
never wrong (ValidateChapterNumber reads the number only up to the first
non-word character), and a logged correction on editor traffic is meant
to signal a gap in the renderer's repair. A wrong, extra or misplaced
marker is still corrected and logged as before.

The saved chapter reads back as the same USX the editor sent, so the
editor has nothing to reload, and re-saving it leaves the file alone.

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

* PT-4608: Refuse a save whose setter has moved on to another chapter

A write that comes back unchanged retries through the closure of the
chapter it was typed in, but read the PDP setter from a ref that
navigation had already re-pointed at the new chapter. Paratext used to
refuse that write for its wrong chapter number; the backend correction
now renumbers the marker instead, so chapter N's text could silently
replace chapter N+1's. The ref now carries the chapter its setter
writes to, and a save bound to another chapter refuses it.

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

* PT-4608: Leave the sent-to-PDP baseline to the write after a push-back

Putting a repaired chapter back into the editor also recorded it as the
last document sent to the PDP, before the write had run. The write can
still be dropped (another write is in flight, or the save now belongs to
another chapter), and the baseline then named a document that never
left: the in-flight write's echo no longer matched it, so the PDP sync
replaced the editor with that older document whenever the editor was not
being typed in, taking the repaired edits with it. The write moves the
baseline itself, synchronously, when it actually starts, so the push-back
no longer touches it.

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

* PT-4608: Correct the marker on screen when the user leaves the editor

A save that runs as the user leaves the editor was treated as a save that
ran mid-typing, because the last keystroke is newer than the debounce by
definition there. So the repaired chapter was written while the editor
kept the wrong marker, and the next edit anywhere in that chapter
corrected it then — taking the caret to the chapter line, away from where
the user had started typing.

The editor is a web view in an iframe, and it stays its own document's
activeElement after the window around it loses focus, so its isFocused()
cannot tell typing here from typing elsewhere in the app. The window's
own focus can, and it now gates both the recency check and the caret
restore: a correction made for a window the user has left reaches the
editor without focusing it, which would otherwise pull the user out of
the control they moved to.

The e2e spec covers both halves: that the window's focus follows the user
out of the editor, and that a marker corrected by the save on leaving is
on screen while the caret stays in the control that was clicked. Without
this change it fails on the caret, which the correction's later, echo-
driven push-back pulls back into the editor.

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

* PT-4608: Put the caret back only once the editor has the corrected chapter

The caret restore ran on one fixed 200 ms delay after the push-back, and
the load it was waiting on has no signal to wait for. When the load took
longer, setSelection addressed the outgoing document, quietly changed
nothing, and left the caret where focus() had put it: the end of the
chapter. The next Backspace then deleted text there, out of sight of the
user, who was working on the chapter line — backspacing a chapter number
away a key at a time lost the last characters of the chapter this way.

The restore now waits until the editor is holding the document the caret
addresses, reads the placement back to confirm it took, and retries until
it does or gives up after two seconds. It also sets the selection BEFORE
focusing rather than after: focus() on an editor with no selection is
what takes the caret to the end of the document, so the caret never
passes through the destructive position at all.

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

* PT-4608: Place the caret only once the corrected chapter has actually loaded

- The caret restore now waits for the outgoing document's first block to
  leave the editor. `getUsj()` returns the incoming document as soon as
  `setUsj` is called, before the load, so with no pending marker edit the
  restore could place the caret in the outgoing tree and then lose it to the
  load.
- The end-of-document restore (a restored marker) goes through the same wait
  instead of one fixed 200ms delay, and is cancellable like the others.
- A placement at a paragraph's boundary 0 is accepted when the editor reads it
  back as that paragraph's marker location (no offset). Before, the removed-
  marker target never matched, so the caret was re-applied every 50ms for 2s.
- `prepareUsjForChapterSave` reuses its first save comparison when nothing was
  repaired instead of comparing the whole chapter again.
- Test and e2e comments describe the behavior rather than its history.

Verified: typecheck, lint, extension unit tests (1618), and the four
chapter-marker isolated e2e specs.

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

* PT-4608: Report Send/Receive and permissions refusals as before this change

Both are again reported on every occurrence, each with its own transient
notice, exactly as they were before the save-failure report existed. Only a
refusal the editor cannot name (the new "could not be saved" notice) is
reported once per run and stays up until a save gets through.

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

* PT-4608: Announce a chapter-marker correction only once it has taken effect

The notice used to be sent before the write, so a write refused afterwards
(its chapter no longer the one selected) left the user told about a
correction that was never made. It now goes out once the corrected chapter
is written, or straight away when there is nothing to write and the editor
itself was corrected (the repair put the chapter back to what is stored).

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

* PT-4608: Hold off edits while the editor moves to another chapter

From the moment another chapter is selected until its content arrives, the
editor still shows the chapter being left. Keys typed there were shown and
then lost (no save can take them: they belong to neither chapter), and each
one moved the caret in the old chapter, which reported its verse and could
pull the reference back to the chapter the user had just left.

`usePauseEditing` now holds those keys off while `isUsjFromPdpLoading`, in
the capture phase above the editor root: editing keys, caret-moving keys and
select-all, plus beforeinput, paste, cut and drop. The editor is NOT made
read-only, so it keeps the focus and the caret; copying and application
shortcuts go through. No keyboard shortcut is added, so the shortcuts
catalog is unchanged.

E2E `chapter-switch-edit-pause.spec.ts` moves chapter the way F8 does
(`platform.goToNextChapter`) while a stream of keys is typed, and checks
that every key the old chapter showed was saved, that keys typed during the
move were held off, and that the editor kept the focus. With the hold-off
disabled it fails: the keys land in the old chapter and pull the reference
back to it.

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

* PT-4608: Force the corrected chapter into the editor

The editor's setUsj now keeps a marker edit in progress when handed the
document from before it (paranext/scripture-editors#19). A repair can be
exactly that document — a chapter number the user backspaced away, put back
— so the push-back passes `{ force: true }` to have it loaded.

Without it the correction still reaches the screen, but only a save later:
once the edit settles, the next save repairs it again and that load goes
through. The chapter-marker e2e specs pass either way; the editor's own
setUsj test is what distinguishes the two.

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

* PT-4608: Record in the ADR that the repair push-back forces its load

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

* PT-4608: Put the caret after every removed marker, not just the first

The caret for a repair that only removed markers goes to the item that
followed the first removed one. When that item was itself a removed marker
(`\c 5` then `\c 6` typed at the end of a chapter), the target addressed a
position past the end of the repaired document, so the caret restore could
never place it and gave up after two seconds, leaving the caret wherever the
load put it. The target is now the first item after the removed marker that
the repair keeps, falling back to the surviving marker when there is none.

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

* PT-4608: Announce a chapter-marker correction as soon as it is on screen

A correction pushed back into the editor was announced only once its write
landed, so a write dropped because another was still running announced
nothing, and the next save found the chapter already correct. A correction
that reaches the screen is now announced at once; one kept off screen (the
user is typing, or has left the chapter) still waits for its write.

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

* PT-4608: Force the editor back to the stored chapter after a refused save

After a Send/Receive or permissions refusal, the editor is put back to the
stored chapter. When the refused edit was a marker edit still in progress,
the stored chapter could equal the editor's record from before that edit,
so the editor skipped the load and kept the refused edit on screen.

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

* PT-4608: Keep a paragraph whole when a chapter marker typed into it is removed

The editor holds a `\c` typed mid-paragraph as the paragraph cut short,
the chapter node, and the rest of the paragraph's text as bare top-level
content. The repair dropped the extra chapter node but left that text
outside any paragraph, and aimed the caret at it, where it did not
resolve. Paratext 9 removes only the marker's own bytes, so the repair
now appends the inline content that followed a removed marker back onto
the paragraph it was cut from, and places the caret at the join.

New isolated e2e chapter-marker-mid-paragraph.spec.ts types `\c 5 `
into Ruth 2:1 and checks the notice, the single chapter marker, the
caret landing in the rejoined text, and the saved paragraph; it fails
on the caret step against the previous repair.

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

* PT-4608: Take down a stale save-failed notice when the editor mounts

The "could not be saved" notice stays up until a later save gets through
and dismisses it, but only a mount whose own failure memory recorded the
failure does that. A reloaded web view keeps its id with empty memory,
so the notice could outlive it indefinitely while saves worked. Dismiss
the editor's save-failed id once on mount.

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

* PT-4608: Drop development history from the backend-correction spec header

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

* PT-4608: Point the repair's undo limitation at PT-4818

The ADR now records that keeping undo history across a correction is
open in PT-4818 (OT ops, a generic history-preserving load, or editor
node surgery) rather than settled on the in-editor repair.

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

* PT-4608: Hold off the Edit menu and toolbar undo/redo while a chapter loads

The Project menu's Edit flyout and the toolbar's Undo/Redo buttons drive
the editor through EditorRef directly, past the DOM events usePauseEditing
cancels, so between selecting another chapter and its arrival they could
edit the chapter being left, an edit that is then dropped. The flyout now
treats a loading chapter as read-only (Copy still runs), and the toolbar
buttons do nothing while it loads, as Ctrl+Z and Ctrl+Y already do.

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

* PT-4608: Hold off footnote, cross-reference and marker palette edits while a chapter loads

Insert Footnote and Insert Cross-reference (context menu, Ctrl+T,
Ctrl+Shift+T, the top menu) and the marker palettes' commits change the
editor through EditorRef, past what usePauseEditing holds off, so between
selecting another chapter and its arrival they could edit the chapter being
left, an edit that is then dropped. Each now checks a ref that tracks the
load; the inserts check it again after their version-history commit, since
the load can start during that await.

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

* PT-4608: Keep the deleted-marker spec's five Backspaces inside the save debounce

`locator.press` focuses its target before every key and can take over a
second a key on a cold first launch. The five Backspaces meant to remove
`\c 2 ` before any save then straddle the 700 ms debounce: a save repairs
the marker partway through and the remaining keys delete the repaired one
down to `\ `, which the editor no longer reads as a chapter, so nothing
repairs it again and the caret step times out.

The keys now go through `mainPage.keyboard` (the glyph click leaves the
focus in the editor), and the step asserts they landed inside the debounce
so a slow run fails on that precondition rather than 20 s later.

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

* PT-4608: Keep a chapter line's leftover text below the restored marker, with the caret

The editor now saves a chapter line's bytes as what they are once they
stop being a chapter marker (`\ 2` after the `c` is deleted, text typed
ahead of the `\c`), as Paratext 9 saves the bytes it holds. The save
repair already restores a later chapter's marker at the top and moves
anything ahead of it below, and leaves chapter 1 without one, all as
Paratext 9's FixChapterNumbers does.

What it did not do was keep the user's caret: a restore sent it to the end
of the chapter, or just past the restored number. When the caret is in a
line of top-level text the repair moved, it now stays at the same
character of that line.

New e2e `chapter-line-left-as-text.spec.ts` covers both edits end to end:
the notice, the restored marker above the text, the caret, and the saved
USFM. The ADR's consequences record the editor half.

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

* PT-4608: Keep the caret in a nameless line the repair moves, and pin `\ 2` loading and saving as itself

The editor now settles a chapter line's bare `\ 2` as the paragraph with
no marker name ParatextData reads it as, rather than as plain text. The
caret the user had in that line follows it below the restored marker: to
the same character of its text, or to the start of the text when it was
inside the `\ `, which holds no caret.

The e2e now also reloads Ruth 2 and saves an edit to the line, pinning
that `\ 2` loads back as itself and is saved as `\ 29`, not `\p`. It
reads the caret as the text before it within the line, so the check does
not depend on how the browser spells the position.

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

* PT-4608: Keep the caret in a marker typed into a chapter marker

Typing an `s` into `\c 2` leaves `\sc 2`, a character style. The repair
restores `\c 2` above it, but the caret went to the end of the document:
the moved-line caret mapping only knew plain text and nameless markers.

The save normally runs before the editor settles the line, so the caret
counts the whole chapter line; it is now placed at the same character of
what the line became, in its marker name or in its text, and a caret read
after the line settled keeps its place in the marker name. Only the line
the stored chapter marker stood on is read this way, since a caret in any
other paragraph's text reads the same.

Also pins end to end that text typed ahead of `\c 1` joins `\mt1 Ruth`, as
it saves and reloads (scripture-editors join on the chapter settle).

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

* PT-4608: Don't read a paragraph the chapter line merged into as the chapter line

A selection from the chapter line into the paragraph below it, typed over,
removes the chapter line and leaves that paragraph where the line stood. The
caret mapping then took the paragraph for what was left of the chapter line
and counted its text as if it began with the paragraph's own marker, so the
caret landed in `\p` and the next key retagged the paragraph (`\pc`).

The stored chapter line's index is now used only when the line changed in
place: the stored line after it is still the one after it. Otherwise the
usual caret applies, as before the moved-line caret was added.

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

* PT-4608: Record in the ADR when the repair keeps the live caret

`adr-chapter-marker-repair-replaces-the-document` still said the caret target
is never carried across from the pre-repair selection. The caret in what is
left of the chapter line is, and is ranked ahead of every computed target.
The entry now says when that caret is read, why its coordinates hold there,
and that a paragraph the chapter line merged into is not read that way.

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

* PT-4608: Hold off the paragraph and character-marker menus while a chapter loads

While another chapter is loading, the editor still shows the chapter being
left and any edit to it is dropped. The paragraph-style picker, the inline
marker menu, the character-marker menu and the character-marker bar's
removal changed the editor without checking for that, so picking a
paragraph style right after navigating retagged a paragraph that was then
silently thrown away.

Each now does nothing while a chapter loads (the menus still close). The
removal checks both before its version-history snapshot, so no empty restore
point is taken, and again after it, since a load can start during the await.

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

* PT-4608: Keep the caret in a paragraph the chapter line merged into

Selecting from the chapter line into the paragraph below and typing over it
merges the two. The repair restores the marker and moves that paragraph down
whole, but the caret then went to the end of the chapter, so the next keys
landed there.

That merge is recognized by the line after the merged paragraph being the
stored line two after the marker. The caret in it addresses the paragraph
alone, so it keeps its path with only the paragraph's index changed.
`chapter-line-left-as-text.spec.ts` covers the gesture in Jonah 2; with the
caret mapping disabled, that step fails.

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

* PT-4608: Read the moved line's caret by where it is on screen, not by its neighbours

When the two lines after the chapter marker were the same, a chapter line
merged into the paragraph below it looked, from its neighbours, like a
chapter line changed in place. The caret was then counted as if it held the
chapter glyph's marker bytes and landed inside the paragraph's marker, so the
next key retagged it (`\pc`).

The glyph arithmetic now applies only while the caret is on a chapter line on
screen (`isSelectionInChapterLine`, read from the web view's DOM selection).
Anywhere else on the line where the stored marker stood, the caret already
addresses the line as the saved document holds it, so its path is kept with
only the line's index changed. That replaces both neighbour comparisons, and
also keeps the caret in a line that has already settled.

`chapter-line-left-as-text.spec.ts` fails with the signal forced off.

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

* PT-4608: Read only the stored chapter line as a chapter line, and pin the caret signal

A nameless paragraph (`\ `) at any other index still had its `\ ` counted in
the caret's offset, so after deleting the chapter line above a `\ 2 more`
line, a caret in that line came back two characters early. The marker-byte
arithmetic now runs only on the line the chapter marker stood on, as the
docs and the ADR already say; elsewhere the usual caret applies.

The on-screen chapter-line signal defaults to `false`, so dropping it at the
save compiled and passed every unit test. A source-reading contract test
now pins it at the call site, and the helper is tested with the selection
anchored on the `p.chapter` element itself.

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

* Add an e2e for markers typed through the `\` palette

The palette's Space commit writes the typed marker's bytes and the editor
turns them into the marker as it settles, with settling held while the
palette is open. Unit tests cover the editor and the key routing separately;
nothing checked the two together in the running app. The spec types `\nd `
and `\v ` through the palette, both waiting for it and typing straight
through, and checks the text typed next lands in the `\nd` span and becomes
the verse number.

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

* Check typed verse numbers Jonah 1 doesn't already have

Jonah 1 already has verses 8 and 9, so the typed-commit spec's verse
checks passed before anything was typed. Use 91 and 92 (Jonah 1 ends at
17) and assert each is absent first.

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

* PT-4608: Address carets in the saved document, as the editor now does

The editor now reports and takes caret positions in the document getUsj()
returns (settled positions), so the repair's caret targets are written
that way too:

- just past a chapter number is the end of its `number` value
  (`$.content[i]['number']`), no longer an offset into the drawn glyph;
- a caret in what is left of a chapter line keeps its exact place there,
  read straight from getSelection(), with only the line's index changed.
  The glyph character counting and the DOM signal that told it when the
  caret was on a chapter line on screen are gone;
- a removed marker's place is spelled canonically (the start of a text,
  a marker's location, or the end of the line), not as a child index on
  its parent.

The caret restore confirms a placement by comparing places rather than
location shapes: the editor reports the end of a text that a marker
follows as the text's end, and two texts side by side as one.

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

* PT-4608: Take a caret the editor put on the next line as placed

A view that hides markers cannot show a caret on a chapter number, so the
editor puts a caret placed just past one at the start of the next
paragraph and reports it there. The caret restore read that as a failed
placement and placed it again every 50 ms for its whole two-second window,
so in the Formatted view a caret the user moved right after a repair was
pulled back to the start of the chapter, and the restore then logged that
the editor had not loaded the chapter in time. Two places with only a line
break between them in the USFM are now the same place.

The text-start alias the deleted scheduler test covered no longer exists:
setSelection/getSelection round-trip the start of a paragraph's text
exactly in all five views.

Also pins the caret for a deleted chapter marker: a caret in the line that
moved up into the marker's place does not stay there.

e2e chapter-marker-formatted-caret.spec.ts fails without the change (the
moved caret is back at the start of the chapter) and passes with it.

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

* PT-4608: Name the caret check for what it accepts

The caret restore's read-back check also accepts a caret at the start of
the line after a target at the end of a line, which is a different USFM
position, so it was wrong to call it a "same place" comparison. It is now
isCaretPlacedAt(usj, caret, target), and it accepts that case in one
direction only: the caret after the target, never before it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
tjcouch-sil added a commit that referenced this pull request Oct 23, 2026
Only the committed platform dist conflicted; rebuilt from the merged source.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform-editor): rebuild committed dist

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Rebuild the committed dist for the clipboard fixes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform-editor): rebuild committed dist

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

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

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

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

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

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

* build(platform-editor): rebuild committed dist

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform-editor): rebuild committed dist

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

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

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

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

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

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

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

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

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

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

Every pin and what it asserts is unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

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

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

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(platform): stop exporting NBSP_STAND_IN_FONT_FAMILY

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

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

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

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

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

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

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

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

* docs(platform): mark toUsfmNbspSpelling experimental

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests adapted from Jolie Rabideau's #36.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform-editor): rebuild committed dist

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Rebuild the committed dist for the clipboard fixes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform-editor): rebuild committed dist

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

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

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

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

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

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

* build(platform-editor): rebuild committed dist

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform-editor): rebuild committed dist

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

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

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

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

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

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

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

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

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

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

Every pin and what it asserts is unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

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

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

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

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

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

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

* build(platform): rebuild the committed dist

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(platform): stop exporting NBSP_STAND_IN_FONT_FAMILY

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

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

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

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

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

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

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

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

* docs(platform): mark toUsfmNbspSpelling experimental

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests adapted from Jolie Rabideau's #36.

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

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

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

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

A selection that reaches into part of an alike span and past it now
extends that span instead of nesting a copy inside it. This deliberately
departs from Paratext 9, which mangles the line in that case (TJ,
2026-10-27). Standard view kee…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants