A Google Docs blank line is one line, not two
Paul pasted One / Two / Three / blank / Four out of Google Docs. The blank line came back two lines tall, and an extra blank line appeared under the paste. Both symptoms, one cause each, and neither was where it looked. Google spells an empty paragraph as a bare <br> SIBLING of the <p>s, inside the <b id="docs-internal-guid-…"> wrapper it puts around a copied selection — not as an empty <p>, and not as a <br> inside one. That distinction is the whole bug: four attempts to reproduce it failed because they all had the <br> inside the paragraph. prosemirror-view's clipboard parser ignores a <br> only when it is the last child of a non-inline parent, so Google's is kept, schema-basic's hard_break rule claims it, and normalizeSiblings wraps the now-stray inline node in a paragraph. The blank line arrives as p(hard_break). Then addTextblockHacks appends <br class="ProseMirror-trailingBreak"> to any textblock whose last child is not text, because contenteditable needs somewhere to put a cursor — so it draws as <p><br><br></p>. The trailing blank line was not Google's at all. Paul's real clipboard bytes end with TWO <br class="Apple-interchange-newline"> after the closing </b>, which WebKit appends to mark where a copy stopped. The parser's last-child rule disposes of exactly one; the first survives. Only the editor was ever wrong. The no-JavaScript read view gets <p><br></p>, and a lone <br> makes exactly one line box; pdf.go's paragraph loop consumes a trailing break without emitting a line. fixtures/paste.mjs fixes both on the way in. transformPasted replaces a paragraph whose children are ALL hard_breaks with that many empty paragraphs — the first moment the distinction is legible, since a trailing <br> inside a <p> was already dropped and a paragraph the author typed always has text in it. A parseDOM rule ignores WebKit's marker by its class; discriminating on the class rather than on position is what keeps a trailing blank line the author really did leave, which Google puts inside its wrapper carrying no class. Nothing new crosses the wire: the output is an ordinary empty paragraph, which fragment.rs, pdf.go and markdown.go have each rendered since before this. Unlike align.mjs, no vocabulary that four separate binaries must keep in agreement. Twelve tests in fixtures/paste.test.mjs, in the gate. The one that matters guards the opposite mistake: a Shift+Enter break inside a line of text must survive verbatim, and every other field of the hard_break spec must come through the patch untouched, since the node's name and shape are the wire format. Also lands Plan 7 (images, links and tables), revision 2 after Fable's review. Its load-bearing decision is that fragment_html's output is template.HTML written unescaped, and is safe only because no byte from a document reaches attribute position: tables keep that property, links and images spend it, so the scheme allowlist is bought once in the shared path — a rejected href emits the run with no link mark at all, so no consumer can see one. Images render by decoration and the node is inserted when the upload returns, per §6.1's rule that placeholders are decorations rather than nodes. Two things it leaves open for Paul: the sealing key, which does not exist anywhere in the app yet, and testing a real googleusercontent fetch before Task 9. Reasoning, the reversals included, is in discussion 23.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+727
−13
.amadan/ci.d/10-gate+10 −0.gitignore+1 −0docs/superpowers/plans/2026-09-02-images-links-tables.md+393 −0fixtures/editor-entry.js+14 −1fixtures/paste.mjs+143 −0fixtures/paste.test.mjs+154 −0internal/docs/static/{editor.5b869295084ed7fb.js => editor.7b30ca1e00c791c0.js}+12 −12
CI passed — run details
landed from worktree-paste-fidelity
.amadan/ci.d/10-gateLoading diff…
.gitignoreLoading diff…
docs/superpowers/plans/2026-09-02-images-links-tables.mdLoading diff…
fixtures/editor-entry.jsLoading diff…
fixtures/paste.mjsLoading diff…
fixtures/paste.test.mjsLoading diff…
internal/docs/static/{editor.5b869295084ed7fb.js => editor.7b30ca1e00c791c0.js}Loading diff…