| 1 | # Plan 5 — the formatting bar, font options, and export |
| 2 | |
| 3 | Brief, from Paul, 2026-08-31: *"the maximally viable checklist — a |
| 4 | formatting bar, some font options, at least PDF and Markdown export."* |
| 5 | |
| 6 | The spec already rules on all four (§11 Fonts/DOCX/PDF/Markdown, §13's |
| 7 | sequencing item 7: "Markdown, then DOCX, then PDF"). This plan is that |
| 8 | item, brought forward past DOCX — DOCX is import *and* export and needs |
| 9 | the schema work §11.2 describes; Markdown and PDF need only what the |
| 10 | CRDT already holds. Nothing here changes a spec ruling. Two places |
| 11 | where this plan reads a ruling differently from a literal reading of |
| 12 | §11 are recorded as D1 and D2 below, for the branch discussion to |
| 13 | settle rather than for this plan to settle by itself. |
| 14 | |
| 15 | ## What exists to build on |
| 16 | |
| 17 | - The document's truth is a `Y.XmlFragment` on root `prosemirror` |
| 18 | (`core/src/fragment.rs`), replayed server-side through wazero. |
| 19 | `fragment_html` and `fragment_text` are its two renderers today. |
| 20 | - `internal/core` exposes that through a C ABI (`docs_core_html_len` / |
| 21 | `_read`, and the text pair). `abiVersion` is 2. |
| 22 | - The browser editor is ProseMirror + Yjs, one committed bundle |
| 23 | (`fixtures/build-editor.mjs` → `internal/docs/static/editor.<hash>.js`). |
| 24 | `prosemirror-schema-basic` + `addListNodes` is the schema; `Mod-b` and |
| 25 | `Mod-i` are already bound to `toggleMark`, so bold and italic exist |
| 26 | today with no way to *see* that they exist. |
| 27 | - Every user-visible string is a catalog key (`internal/docs/render.go`). |
| 28 | |
| 29 | ## The shape of the work |
| 30 | |
| 31 | Seven tasks. 1–3 are server-side and touch none of the files the |
| 32 | concurrent caret-placement branch touches; 5–6 touch the editor bundle |
| 33 | and are deliberately sequenced after that branch merges. |
| 34 | |
| 35 | ### Task 1 — the core renders a structured document, not only markup |
| 36 | |
| 37 | `fragment.rs` gains `fragment_json`, a third renderer over the same |
| 38 | walk: ProseMirror's own JSON shape (`{"type":…,"attrs":…,"content":[…]}`, |
| 39 | text nodes as `{"type":"text","text":…,"marks":[…]}`), hand-written with |
| 40 | no serde dependency (the crate has none outside dev-dependencies, and |
| 41 | this is forty lines of escaping). |
| 42 | |
| 43 | The ABI gains `docs_core_json_len` / `docs_core_json_read`, the same |
| 44 | len-then-read pair the other two use, and `abiVersion` goes to 3. |
| 45 | |
| 46 | **Why a third renderer rather than parsing the HTML in Go.** Markdown |
| 47 | and PDF both need structure — where a list ends, which run is bold, |
| 48 | which paragraph is a heading. Go's standard library has no HTML parser, |
| 49 | and `golang.org/x/net/html` is a new module dependency this repo does |
| 50 | not have and cannot fetch offline. Re-deriving structure from our own |
| 51 | markup would also mean two parsers to keep honest against one emitter. |
| 52 | One structured export, two consumers in Go, is fewer moving parts than |
| 53 | either alternative. |
| 54 | |
| 55 | **Unknown nodes still render their children** — `fragment.rs`'s module |
| 56 | rule, and it holds here for the same reason: a reader (or an exporter) |
| 57 | silently dropping text is data loss, not a degradation. |
| 58 | |
| 59 | ### Task 2 — font and size as marks the core knows |
| 60 | |
| 61 | Five new marks, all attribute-free: `font_sans`, `font_mono` (serif is |
| 62 | the document default and carries no mark), and `size_sm`, `size_lg`, |
| 63 | `size_xl` (normal carries no mark). |
| 64 | |
| 65 | `fragment.rs`'s `MARKS` table gains them, emitting a `<span>` with a |
| 66 | fixed class — `<span class="docs-font-sans">` — chosen by lookup from a |
| 67 | compile-time table. |
| 68 | |
| 69 | **Attribute-free marks, not a `fontFamily` mark with an attribute.** |
| 70 | `Store.HTML` returns `template.HTML` — markup trusted to be written out |
| 71 | unescaped — and its doc comment says exactly what earns that trust: |
| 72 | `fragment.rs` escapes every text run *and emits no attributes at all*. |
| 73 | A mark carrying a value from the CRDT into attribute position would put |
| 74 | client-supplied bytes inside a quoted attribute, which is a different |
| 75 | escaping problem with a different set of ways to get it wrong (and the |
| 76 | CRDT accepts arbitrary client-posted bytes — `POST /d/{sqid}/updates` |
| 77 | does not go through ProseMirror's schema). A fixed set of marks whose |
| 78 | class names are constants in this binary keeps the property that made |
| 79 | the `template.HTML` return safe in the first place: no byte from a |
| 80 | document ever reaches attribute position. |
| 81 | |
| 82 | The cost is that the set is closed — a new size is a core change and a |
| 83 | new binary, not a document that names its own point size. For "some |
| 84 | font options" that is the right trade; §14 is where an open-ended font |
| 85 | model goes if it is ever wanted. |
| 86 | |
| 87 | ### Task 3 — `internal/fonts`: the core set, embedded |
| 88 | |
| 89 | Liberation Serif, Sans and Mono, four faces each (regular, bold, |
| 90 | italic, bold-italic), embedded with `//go:embed` and served at |
| 91 | `/static/fonts/…` behind the same immutable caching the editor bundle |
| 92 | uses. |
| 93 | |
| 94 | **D1 — Liberation, where §11.1 names Carlito and Caladea.** §11.1 asks |
| 95 | for two things that are separable, and this plan does the first |
| 96 | without the second. The first is *a curated core set, self-hosted, the |
| 97 | same font programs on the screen and in server-side layout, so both |
| 98 | measure identically* — that is what makes a PDF match what the writer |
| 99 | saw, and it is met here. The second is *metric-compatible substitution |
| 100 | for the proprietary faces a DOCX import names*: Calibri → Carlito, |
| 101 | Cambria → Caladea. That mapping only has a job once DOCX import exists |
| 102 | to name Calibri, and there is no DOCX import. Liberation is the same |
| 103 | idea one family over (it is the metric-compatible set for Times New |
| 104 | Roman, Arial and Courier New), it is OFL-licensed, and it is what is |
| 105 | actually available to this build. When DOCX import lands, Carlito and |
| 106 | Caladea join the payload; nothing here forecloses that. |
| 107 | |
| 108 | **woff2 is not in this plan.** §11.1 says woff2 and means it — the TTFs |
| 109 | are roughly twice the bytes. Converting requires `woff2_compress` or |
| 110 | fontTools, neither of which is available here, and hand-writing a |
| 111 | Brotli-based woff2 encoder to save download bytes is not a good use of |
| 112 | this branch. The faces are declared with `@font-face` and a browser |
| 113 | fetches only the faces a page actually uses, so an unused face costs |
| 114 | nothing; the conversion is a follow-up with a real trigger (the first |
| 115 | time page weight is measured and this shows up in it). |
| 116 | |
| 117 | ### Task 4 — `internal/pdf`: a real PDF writer |
| 118 | |
| 119 | Pure Go, no new module dependencies, `CGO_ENABLED=0` like everything |
| 120 | else: |
| 121 | |
| 122 | - **A TrueType reader** (`head`, `hhea`, `maxp`, `hmtx`, `cmap` format |
| 123 | 4, `loca`, `glyf`) for advance widths and glyph outlines. |
| 124 | - **A subsetter**: the glyphs a document actually uses, with `glyf`, |
| 125 | `loca` and `hmtx` rebuilt and composite glyphs' components followed |
| 126 | so an accented character does not lose its accent. |
| 127 | - **The writer**: objects, an xref table, Flate-compressed content |
| 128 | streams, and a Type0/CIDFontType2 font with Identity-H encoding plus |
| 129 | a `ToUnicode` CMap, so text in the exported PDF is selectable and |
| 130 | searchable rather than a picture of text. |
| 131 | - **Layout** from the same page geometry the screen uses: US Letter, |
| 132 | 1in margins, 12pt/1.6 body — the constants live in Go and |
| 133 | `internal/docs` has a test that fails if `views/show.html` stops |
| 134 | agreeing with them. §11.3's "the same box tree the screen renders" |
| 135 | is a claim about the *engine* the layout plan will build; until that |
| 136 | exists this is the honest version — one set of constants, asserted |
| 137 | equal in both places, and a note that they are asserted rather than |
| 138 | shared. |
| 139 | |
| 140 | **D2 — this is not the layout engine.** §13 says the layout engine |
| 141 | cannot slip because pagination, PDF and thumbnails all fall out of it, |
| 142 | and the engine is not built. What Task 4 builds is a line-breaker and a |
| 143 | paginator for the document shapes that exist today (paragraphs, |
| 144 | headings, lists, blockquotes, inline marks) — real text measurement |
| 145 | from real font metrics, not an approximation, but it knows nothing |
| 146 | about tables, images, floats or widow/orphan control. When the layout |
| 147 | engine lands, this becomes its back end (the writer, the subsetter and |
| 148 | the font work all survive) and the line-breaking half of it is |
| 149 | replaced. Recording it here so a later reader does not mistake this for |
| 150 | the engine and build the engine's other consumers on top of it. |
| 151 | |
| 152 | ### Task 5 — the formatting bar |
| 153 | |
| 154 | `fixtures/toolbar.mjs`, a new module under the 300-line cap, wired into |
| 155 | `editor-entry.js` in a few lines. Bold, italic, code; paragraph and |
| 156 | headings 1–3; bullet and ordered lists; blockquote; undo and redo; a |
| 157 | font-family control and a size control driving Task 2's marks. |
| 158 | |
| 159 | **Link is cut, on the same reasoning as D-marks-carry-no-value.** A |
| 160 | `link` mark carries an `href`, and the read view renders server-side |
| 161 | through `fragment.rs` — so supporting it means putting a value from the |
| 162 | CRDT into attribute position, which is the one property Task 2 exists |
| 163 | to preserve, plus a URL-scheme allowlist (`javascript:` is a URL). |
| 164 | Neither is hard; both are decisions rather than rows in a table, and |
| 165 | they are not decisions to make at the end of a branch. Note that |
| 166 | `prosemirror-schema-basic` already *defines* the link mark, so a paste |
| 167 | can still produce one today and `fragment.rs` already renders it as |
| 168 | plain text — that asymmetry predates this branch and is unchanged by |
| 169 | it. |
| 170 | |
| 171 | Chrome, so it lives outside `[docs-editor]` — ProseMirror's |
| 172 | `{mount: el}` takes over everything inside that element. Buttons |
| 173 | reflect the selection's active marks, targets are 24×24 (§10.3, WCAG |
| 174 | 2.2 AA, no exemptions), every label is a catalog key rendered |
| 175 | server-side into the markup the way the save-status copy already is — |
| 176 | the bundle carries no copy of its own. |
| 177 | |
| 178 | Each command dispatches one transaction, so one toolbar click costs one |
| 179 | retained update, not several: `internal/core`'s `maxRetainedUpdates` is |
| 180 | 4096 with no compaction yet, and toolbar-driven editing spends it |
| 181 | faster than typing does. |
| 182 | |
| 183 | ### Task 6 — the export routes and where a person clicks them |
| 184 | |
| 185 | `GET /d/{sqid}/export.md` and `GET /d/{sqid}/export.pdf`, both inside |
| 186 | the `RequireSession` group and both owner-scoped through the same |
| 187 | `Store.Get` every other document route uses — a document someone else |
| 188 | owns 404s identically to one that does not exist (§4.2), with no |
| 189 | separate check that could 403 by mistake. |
| 190 | |
| 191 | `Content-Disposition: attachment` with a filename derived from the |
| 192 | document title, sanitised to a conservative set and falling back to the |
| 193 | sqid — a title is arbitrary user text and a header is the wrong place |
| 194 | to find that out. |
| 195 | |
| 196 | The controls go in `show.html`'s page header next to sign-out. |
| 197 | |
| 198 | ### Task 7 — the gate |
| 199 | |
| 200 | `.amadan/ci.d/10-gate` already covers everything this adds (Rust tests, |
| 201 | Go tests, the bundle-freshness check, gofmt). What this task adds is |
| 202 | the tests themselves: fragment JSON fixtures on the Rust side, Markdown |
| 203 | golden files, a PDF that is parsed back and asserted structurally |
| 204 | (pages, fonts embedded, text extractable) rather than byte-compared, |
| 205 | the geometry agreement test, and a toolbar test under `node --test` for |
| 206 | whatever logic can be tested without a DOM. |
| 207 | |
| 208 | ## What executing this actually found |
| 209 | |
| 210 | **The editor stopped working entirely, and nothing caught it.** Between |
| 211 | Task 5 being written and Task 5 being finished, typing did nothing and |
| 212 | every toolbar command did nothing — no error in the page, no error in |
| 213 | the server log, a green Go suite, and a screenshot that looked right, |
| 214 | because the bar renders correctly and the document looks untouched when |
| 215 | it *is* untouched. |
| 216 | |
| 217 | The cause: `y-prosemirror`'s exported `undo` ignores its `dispatch` |
| 218 | argument and undoes immediately. A ProseMirror command's contract is |
| 219 | that a null dispatch performs nothing and only reports whether it |
| 220 | could, and that is exactly how a toolbar decides whether to grey a |
| 221 | button out — on every state update, because that is when the answer |
| 222 | changes. So the bar asked "could I undo?" after every change and the |
| 223 | library answered by undoing it. `undoCommand`/`redoCommand` are the |
| 224 | library's own dispatch-aware pair. |
| 225 | |
| 226 | This is the shape AGENTS.md already warns about — a tool that answers |
| 227 | confidently rather than failing — so it is recorded there too. |
| 228 | |
| 229 | **The gate that found it did not exist, so it does now.** |
| 230 | `scripts/browser-drive.sh` + `fixtures/browser-drive.mjs` drive the real |
| 231 | editor in a real headless Chrome over the DevTools protocol, with real |
| 232 | input events (a synthetic DOM Range is not the selection ProseMirror |
| 233 | reads, and an `element.click()` skips the `mousedown` the toolbar |
| 234 | depends on). Twelve checks; it was verified to FAIL on the broken build |
| 235 | before being kept, which is the only way to know a new test can tell |
| 236 | "no" from "I can't see it". It is not in `.amadan/ci.d/10-gate`: it needs |
| 237 | a Chrome on the runner, and that is a decision for whoever owns the |
| 238 | runner rather than a side effect of this branch. Spec §12 asks for this |
| 239 | gate; this is the first half of it. |
| 240 | |
| 241 | ## Risks |
| 242 | |
| 243 | - **The bundle is a single committed artifact and only one may exist** |
| 244 | (`assets.go` panics at init otherwise). This branch rebuilds it once, |
| 245 | at the end, after the concurrent caret-placement branch has merged — |
| 246 | a rebuild is not hand-mergeable. |
| 247 | - **`maxRetainedUpdates` = 4096, no compaction.** Not this branch's to |
| 248 | fix, made worse by this branch. Task 5's one-transaction-per-command |
| 249 | rule is the mitigation available without opening compaction. |
| 250 | - **PDF is the long pole.** If it does not finish, Markdown, the |
| 251 | toolbar and the fonts still ship on their own and the branch is |
| 252 | still worth merging; the reverse is not true, so PDF goes last of |
| 253 | the server-side tasks and first of the ones that could be cut. |
| 254 | |