Paul asked for "the maximally viable checklist — a formatting bar, some font options, at least PDF and Markdown export". All four are on this branch, working end to end in a browser. Plan: docs/superpowers/plans/2026-08-31-formatting-and-export.md.
Five commits, read as one diff. .amadan/ci.d/10-gate is green locally.
What is here
The core reports structure. fragment.rs gains fragment_json, a third renderer over the same walk, emitting ProseMirror-shaped JSON. Markdown and PDF both need to know where a list ends and which run is bold, and Go's standard library has no HTML parser to re-derive that from the markup. ABI 2 → 3.
Font and size are marks. font_sans, font_mono, size_sm/lg/xl, all attribute-free, rendered as spans with fixed class names. Store.HTML returns template.HTML because fragment.rs escapes every text run and puts no byte from a document into attribute position; POST /d/{sqid}/updates accepts arbitrary Yjs bytes that never met ProseMirror's schema, so a mark carrying its own value would spend that property. Serif and normal carry no mark — they are what a document is when nobody has said otherwise. Link is cut for the same reason (an href is a value in attribute position plus a scheme allowlist — a decision, not a row in a table).
A core font set. Liberation Serif/Sans/Mono, twelve faces, OFL, embedded and served at content-hashed paths, declared as @font-face on the document page. The PDF exporter measures with the same font programs the browser is served, which is what §11.1 is actually for. Two deviations, both recorded in the plan as D1: Liberation rather than the Carlito/Caladea §11.1 names (those are DOCX-import substitutions and there is no DOCX import), and TTF rather than woff2 (no compressor in this build; a browser fetches only the faces a page uses).
Markdown and PDF. internal/export writes both. internal/pdf is a real PDF writer — xref, Flate streams, Type0/CIDFontType2 with Identity-H and a ToUnicode CMap so exported text can be selected and copied rather than being a picture. The subsetter keeps glyph ids where they are and empties unused glyphs, which is still a real subset (~25KB of a 385KB face) and removes the two places renumbering goes wrong.
A formatting bar. fixtures/toolbar.mjs, its own module, wired into editor-entry.js in a few lines; the resync path is untouched. Undo/redo, a style select, font and size selects, bold/italic/code/lists/quote. Labels all from the catalog.
Two export routes, inside RequireSession, owner-scoped through the same Store.Get as every other document route, with a sanitised download filename.
The thing worth reading this branch for
Mid-branch the editor stopped working entirely. Typing did nothing. Every command did nothing. No error in the page, none in the server log, the Go suite green throughout, and a screenshot that looked correct — because the bar renders fine and the document looks untouched when it is untouched.
y-prosemirror's exported undo is:
export const undo = state => yUndoPluginKey.getState(state)?.undoManager?.undo() != null
It takes no dispatch. ProseMirror's command contract is that a null dispatch performs nothing and only reports whether it could — which is exactly how a toolbar decides whether to grey a button out, on every state update, because that is when the answer changes. So the bar asked "could I undo?" after every change and the library answered by undoing it. undoCommand/redoCommand are the dispatch-aware pair. The keymap keeps the bare undo/redo, correctly: a keymap never passes a null dispatch.
It is in AGENTS.md now, under tools that answer wrongly instead of failing. The general form is worth more than the instance: a convention is not a guarantee, and the functions that break one are the ones that look most like they honour it.
The gate that found it did not exist, so it does now. scripts/browser-drive.sh + fixtures/browser-drive.mjs drive the real editor in headless Chrome over the DevTools protocol, with real input events (a scripted DOM Range is not the selection ProseMirror reads; an element.click() skips the mousedown the toolbar depends on). Twelve checks. It was verified to go red on the broken build before being kept — a new test that has never been seen to fail cannot tell "no" from "I can't see it". Spec §12 asks for this gate; this is the first half of it.
It is deliberately not in .amadan/ci.d/10-gate: it needs a Chrome on the runner, and that is a decision for whoever owns the runner, not a side effect of this branch. Flagging it as a decision for Paul.
What is verified, and what is not
Verified: the full gate locally; the browser drive 12/12; a real browser editing a seeded document, the edit surviving the CRDT round trip and coming back out of the server-rendered read view with the right classes; and both exports served from the running app with the right headers and content.
Not verified: no third-party PDF reader has opened one of these files. None is available in this build, and headless Chromium renders PDFs blank here — which cannot distinguish a bad file from no viewer, so I am not counting it. The PDF is checked by reading it back through its own xref offsets and reconstructing the text through each font's ToUnicode CMap, plus the three structural properties that do not depend on agreeing with a reader written by the same hand (dangling references, /Size, xref offsets). DOCS_PDF_SAMPLE=<path> go test ./internal/export -run TestWriteSamplePDF writes one to open by hand. Worth someone opening one in Preview or Acrobat before this ships.
For Paul
Three things are yours rather than mine:
- The copy. Every new user-facing string is a catalog key in
render.go (docs.editor.toolbar.*, docs.export.*) and none has been through a copy review. The full list is in that file; they are the toolbar's control names and two download links. - The title on the exported page. Both exports put the document title at the top — the PDF as an H1 on page 1, Markdown as its own H1. The screen does not: the title is chrome around the paper, not on it. I chose consistency between the two exports over avoiding the occasional duplicate for someone who also typed their title as a heading. Easy to reverse.
- Whether the browser drive joins the gate, per above.
Not merging until this has had its highest-capability-model review and Paul has ruled on those three.
— claude~paul@keymail.dev, session "checklist formatting and export"