oficina / docs Public

worktree-typography: paragraph spacing, line breaks, and the PDF's missing left margin closed

On branch worktree-typography

Three things that are currently wrong in how a document renders. Opening this before the first commit.

1. Paragraph spacing does not match a word processor

Paul pasted a list from Google Docs — four lines made with plain returns, one blank line before the last — and it came across as four widely-spaced paragraphs with a hole in the middle.

The structure was faithful; the spacing was not. Our CSS is [docs-text] p { margin-block: 0 1em } with line-height: 1.6. Google Docs' "Normal text" is 0pt before, 0pt after, 1.15 line spacing. So in Docs consecutive paragraphs stack like lines and a visible gap requires an empty paragraph; here every paragraph carries a 12pt gap, and an empty one carries two.

This is worth stating precisely because the obvious diagnosis is wrong: Docs has paragraphs, its Enter makes one, and ours already matches. The difference is entirely spacing.

Changing to:

  • p { margin-block: 0 }
  • line-height: 1.6 → 1.4 — Docs is 1.15 at Arial 11pt; 1.4 suits 12pt on a serif and still tightens things considerably
  • p:empty { min-block-size: 1lh }

That last one is the part that would otherwise ship broken. An empty paragraph is currently visible in the server-rendered read view only because it contributes that 1em margin. Remove the margin and <p></p> collapses to zero height — so a blank line would still show while editing (ProseMirror puts a <br> in an empty paragraph) and silently vanish from the read view, the PDF and the Markdown export. Same document, two different shapes.

2. Shift+Enter does nothing

hard_break is in the editor's schema — it comes with prosemirror-schema-basic — but baseKeymap binds only Enter, so nothing inserts one. And core/src/fragment.rs has no case for the node, so even if the browser produced one, the read view and both exports would drop it silently.

With paragraph spacing fixed this becomes the thing you reach for when you want a break inside a paragraph, so it belongs in the same change. Three layers: the keymap, the Rust renderer's HTML and text output, and its JSON, which is what the PDF and Markdown exporters read.

3. Every PDF has no left margin

internal/export/pdf.go:523 draws text at x := ctx.indent, and indent starts at zero — blockContext{scale: 1} at lines 218 and 222. It is only ever added to, for blockquote and list indentation. The vertical cursor starts correctly at MarginPt (line 267); the horizontal never does.

So body text starts at the page edge, and because wrapping still uses the correct 6.5in content width, every page also has a two-inch gutter on the right. MarginPt = 72 was right all along; nothing applied it horizontally.

The fix keeps indent meaning "extra indent beyond the margin" and adds the margin at draw time, which touches the three places that compute an x: the text draw, the blockquote rule, and the list marker.

No test asserts where text starts on a page, which is why a whole-page offset survived a review and shipped. Adding one is part of this.

Not in this branch

Paragraph alignment and the sans-serif default. Both are real and both are agreed; each establishes a pattern that wants its own review rather than riding along with a spacing fix. They follow immediately.

Drove the real editor to close the one gap the implementer flagged honestly: it had proven the hard_break node name by reading prosemirror-schema-basic and y-prosemirror plus Rust tests, which is evidence about the libraries and none about this app's running bundle — a keymap bound to a name the schema does not have would pass every Go and Rust test we have.

Typed ALPHA, Shift+Enter, BRAVO, Enter, CHARLIE in headless Chromium against the built bundle:

  • paragraphs: ["ALPHABRAVO", "CHARLIE"], one real <br>
  • so Shift+Enter broke the line inside a paragraph and Enter split the block, which is the Docs/Word pair
  • after a reload — a full round trip through fragment.rs and back into the editor — the same shape came back: ["ALPHABRAVO", "CHARLIE"], one <br>

No console errors and no uncaught exceptions on the page. The reload is the part that matters most: it proves the Rust renderer emits the break and the browser re-parses it, which is the join the three-layer change could most easily have got wrong in a way no unit test would see.

Merged as 8777e55 and deployed; docs.bds.oncarlos.com is serving it. Landing recorded.

Review found two things and both are fixed: a comment claiming Shift+Enter used to fall through to the browser (prosemirror-view captures and preventDefaults every Enter, so it did nothing at all — and the wrong reading it invited was that the break sometimes worked before), and three PDF spacing constants this change turned into load-bearing zeroes with nothing pinning them to the stylesheet. The new geometry cases take the last field of the margin-block shorthand, because a first-number pattern would read 0 out of '0 1em' and pass vacuously.

Recorded, not fixed: a whitespace-only paragraph still collapses in the read view because :empty cannot match it; the blockquote rule has a gap at blank lines in the PDF; 1lh has no fallback for pre-2023 browsers, where the failure mode is exactly the old behaviour.