oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

Word-processor basics — menus, tabs, autoformat, sizes and pages

Brief, from Paul, 2026-09-02, going into an unattended overnight run: "implement a full-stack of the expected basic features that our word processor should have. Starting with things like: tabs, detecting bullets, font-size, a menu system with e.g. Export rather than random buttons... fix these selected states to just have simple borders not the weird rounded one-side... selects should be Rastrillo design system searchable selects... multiple pages, and the ability to switch between paged and infinite mode... anything else that a Fable brainstorm recommends for a maximal viable Docs app."

Four things were settled by Paul before he signed off, and are not reopened here:

  • Base. Work stacks on origin/main (7507a33). See §1.1 — the first thing this spec got wrong.
  • "Tabs" means both. The Tab key (indent, list demote, Shift+Tab outdent) is built now. Google-Docs-style document tabs — named sections in a rail, each its own sub-document — are specified here (§12) and built later.
  • The menu system is a full menu bar, File / Edit / View / Insert / Format / Tools, above the toolbar. It replaces the two export buttons currently pinned to the formatting bar.
  • Pages are infinite by default, paged on request.

1. What is actually there, and what constrains it

1.1 The base, and the mistake that nearly cost an hour

This spec's first draft was written against a local main ref that had not been fetched, and concluded that 39 commits of finished work — the toolbar, fonts, PDF and Markdown export, alignment, the sans default, live sync, search, snapshots, mobile margins — were unlanded and needed taking through the gate. All of it was already on origin/main. The peer session doing discussions triage caught it within minutes.

This is not a new lesson; it is the second entry under AGENTS.md's "Watch for tools that answer wrongly instead of failing", verbatim: "Citing your working tree instead of origin/main. A checkout parked on a feature branch gives you true statements about the wrong tree." The check that would have caught it is one command — git fetch origin before git rev-list --count origin/main..<branch> — and the reason it was skipped is that git log on the main checkout answered, fluently and wrongly, instead of failing.

It is recorded here rather than in a commit message because the same trap is sitting in front of whoever implements this spec: several branches (worktree-import-documents, the *-squashed family) are still ahead of origin/main tonight, and the same wrong reading is available at every one of them.

1.2 The editor today

origin/main at 7507a33 is a genuinely working editor: ProseMirror in the browser over a Yjs document, a Rust CRDT core (core/) compiled to wasm and run server-side under wazero, and a server-rendered read view that works with scripts disabled.

Already shipped, and not to be rebuilt:

| Capability | Where | |---|---| | Undo/redo, bold/italic/code, block type (¶, H1–H3) | fixtures/toolbar.mjs | | Alignment (left/centre/right) | fixtures/align.mjs, ALIGNMENTS in fragment.rs | | Font face (sans/serif/mono), 12 embedded faces | fixtures/fonts.mjs, internal/fonts | | Size, as four named steps | size_sm/size_lg/size_xl marks | | Bullet and ordered lists (by button) | wrapInList in toolbar.mjs | | PDF and Markdown export | internal/export, internal/docs/export.go | | Live sync across tabs (250ms) | fixtures/live-updates.mjs | | Search, and Ctrl-K jump-to | internal/docs/search.go, fixtures/jump-to.mjs | | Snapshots, so a document stops going read-only | internal/core | | Paste fidelity from Google Docs | fixtures/paste.mjs | | Roving-tabindex toolbar, ARIA toolbar semantics | fixtures/toolbar-roving.mjs |

Genuinely absent, and therefore this spec's actual scope: no Tab binding anywhere (editor-entry.js's keymap is Mod-z/y/b/i plus baseKeymap), no inputRules import at all, no menu, no pagination, and no link mark.

1.3 The invariant everything else is subordinate to

core/src/fragment.rs renders the CRDT to HTML. Its output is returned as template.HTML by Store.HTML and written unescaped by views/show.html. That is safe today because of one property the module states and maintains:

> Every one of these is attribute-free on the CRDT side — the mark's > presence is the whole of its meaning — and the markup each one emits > is a compile-time constant chosen by lookup from this table... A > mark carrying a value — link's href, or a fontFamily naming its > own face — would break that, and POST /d/{sqid}/updates accepts > arbitrary client-supplied Yjs bytes that never went through > ProseMirror's schema, so the value would be genuinely arbitrary.

Two consequences that decide two of this spec's features:

  1. Font size cannot be a valued mark (§6). A mark carrying "14pt" puts a document-supplied byte into attribute position. The enumeration in §6 is not a stylistic preference; it is what keeps this property.
  2. Links spend the property deliberately (§10), and therefore inherit Plan 7's scheme allowlist in full. That plan (docs/superpowers/plans/2026-09-02-images-links-tables.md) is already written and Fable-reviewed. §10 does not restate it — it commits to implementing it as written.

Anything the toolbar or a menu can produce that fragment.rs cannot round-trip is silent data loss, which is the module comment's own phrase for it. Every feature below states what it costs in MARKS, ALIGNMENTS, node names, wire fixtures and the wasm rebuild.

1.4 Who else is in this repo tonight

Three sessions are working in parallel, and the boundaries were agreed directly rather than assumed:

  • fixtures/browser-drive.mjs is not ours tonight. The discussions- triage session owns it and is fixing a live WCAG 2.2 SC 1.4.10 failure with it: list.html's header is scrollWidth 356 against a 320px viewport, offenders button 356, button 350, form[docs-search-form] 356. New browser checks go in their own file and are folded in afterwards.
  • The 320px reflow check must stay green and must not be weakened. The menu bar lands in a page header, which is exactly where that failure lives. §3.4 is not decoration.
  • Links are ours; images and tables are not. The paste session confirmed it is not implementing Plan 7 tonight. Images are blocked on a sealing-key decision from Paul regardless. Tables are unclaimed and out of scope here.

1.5 Two facts about the toolchain, learned the expensive way

  • Chromium cannot start inside the sandbox (scripts/browser-drive.sh: "socket() failed: Operation not permitted"). Anything needing a real browser runs outside it or goes to Paul.
  • *The gate's bundle check requires the rebuilt bundle to be committed, not merely rebuilt*, and on failure it runs git checkout -- and git clean -fd over internal/docs/static/, deleting the bundle just built. Rebuild and commit in one step. The gate also needs a writable Go cache: GOCACHE must point somewhere writable, since ~/.cache/go-build is read-only under the sandbox.

2. Rastrillo: the upgrade Paul expected is not the one we need

Paul's brief says searchable selects "might need to get the latest Rastrillo". Checked, and the condition is false: ui/select.js is byte-identical between the pinned v0.22.0 and the latest v0.25.0 (11,919 bytes, both). More than that, internal/docs/render.go already serves it — vendoredStaticFS maps static/select.js to ui.SelectJS(), and the shell's <head> links it unconditionally.

So the searchable select needs no upgrade at all, only the data-rst-select opt-in on the three toolbar selects (§5).

The upgrade was nevertheless attempted, to see what it costs: the module path changes to amadan.net/rastrillo/rastrillo, ten Go files change their import line, and all six packages pass. It is safe, but it carries a 138-line tokens.css change that nothing in this repo can visually verify tonight (§1.5 — no browser in the sandbox), and it would collide, repo-wide and trivially, with every branch in flight.

Ruling: the upgrade does not ride along with this work. It is its own change, on its own branch, merged only once someone has looked at the result in a browser. Bundling an unverifiable visual change into a feature push nobody is awake to check is how a night's work becomes a morning's bisect.

3. The menu bar

3.1 What it replaces

Two ghost buttons, PDF and MD, pinned to the trailing end of the formatting bar (show.html's [docs-doc-actions]). They are Paul's "random buttons": export is a File-menu verb everywhere a person has met one, and putting it in the formatting bar puts a document-level action inside a run-level control group.

3.2 Contents

What of this actually shipped, since the list below is the plan and not the record. Built and reachable: the whole bar; File's New document, Open, both downloads, Print and Sign out; Edit's Undo, Redo and Select all; View's Paged/Infinite pair; Format's marks, paragraph styles, alignments, lists, quote, indent/outdent and Clear formatting; Tools' Word count and Keyboard shortcuts. Rendered and greyed, each naming the section it waits on: Insert's Link, Image, Table, Page break and Horizontal line, Edit's Find and replace, View's Show ruler.

Not built and not in the menu at all: Rename, Make a copy, Page setup, Cut/Copy/Paste, Full screen, Special characters, Spelling. The list below still names them because it is the design; they are called out here so nobody reads an unmarked line as a promise that was kept. Cut/Copy/Paste in particular should not be built — a <button> cannot drive the clipboard reliably (execCommand is deprecated, navigator.clipboard.read is permission-gated and absent in Firefox), and a menu item that sometimes fails is worse than one that is not there.

Real tonight unless marked. Items that are present-but-disabled are listed because a menu that hides what the app will obviously grow is a menu that gets redesigned; one that greys it is a menu that gets filled in. Nothing is listed that has no plan behind it.

Submenus are flattened rather than nested, for the reason §3.4 gives.

File — New document · Open (goes to the list) · Rename · Make a copy · divider · Download as PDF · Download as Markdown · Print · divider · Page setup · divider · Sign out

Edit — Undo · Redo · divider · Cut · Copy · Paste (the three are present and route to the browser's own clipboard behaviour, with their real shortcuts shown; they are not re-implemented) · divider · Find and replace · divider · Select all

View — Paged / Infinite (a checked pair, §8) · Show ruler (disabled, arrives with tab stops, §7.3) · divider · Word count · divider · Full screen

Insert — Link · Image (disabled, Plan 7, blocked on the sealing key) · Table (disabled, Plan 7) · divider · Page break (§9.3) · Horizontal rule · divider · Special characters (disabled)

Format — Bold · Italic · Code · divider · Normal text · Heading 1 · Heading 2 · Heading 3 · divider · Align left · Align centre · Align right · divider · Bulleted list · Numbered list · Indent · Outdent · divider · Clear formatting

Font and Size are deliberately not in the Format menu. They are the two controls whose current value has to be visible — a person needs to see that they are in 12pt Sans without opening anything — and a menu item cannot show that the way the toolbar's select does. Duplicating them as eleven point sizes and three faces would also make Format the longest menu in the app to say what the bar beside it already says.

Tools — Word count · divider · Keyboard shortcuts · divider · Spelling (disabled — the browser's own spellcheck is on; this item would be a second, worse one)

Every string is a catalog key in render.go's Catalog. There are roughly fifty. A hardcoded English string is a bug (AGENTS.md).

3.3 The two-ways-to-do-everything problem

A menu bar over an existing toolbar duplicates most of the toolbar. That is correct and deliberate — it is what every word processor does — but it has to be principled, or the two drift:

  • One command table, two surfaces. fixtures/toolbar.mjs already owns the command table (data-docs-command resolved against named commands, held honest by TestToolbarOptionValuesMatchTheBundle, which greps this file's source for the literal name). The menu dispatches into the same table by the same names. A menu item with no command is a test failure, not a silent no-op.
  • The toolbar keeps only what benefits from being one click away and from showing state: undo/redo, block, font, size, B/I/code, align, lists. The menu carries everything, including those.
  • State flows one way. The ProseMirror plugin that already keeps toolbar aria-pressed in step (toolbar.mjs's plugin, updating on every selection change including remote ones) also sets menu items' aria-pressed. Nothing listens for clicks to infer state.

aria-pressed, not the aria-checked this said first: these menus are disclosures whose items are ordinary <button>s, and aria-checked belongs to menuitemcheckbox and menuitemradio. Putting an attribute on an element it does not apply to is worse than putting none on at all.

3.4 Keyboard, ARIA, and 320px

The app's rule is WCAG 2.2 AA with no exemptions, so this is a requirement list, not a wish list.

The menu is <details>/<summary>, and stays a disclosure. This reverses what this section said in its first draft, which was role="menubar" with menuitem children and a JS-driven roving tabindex. The Sheets session — building the same chrome tonight — asked which of the two we had picked, and checking the answer showed the menubar version was a regression:

  • The export links work today with scripts disabled. <a href="/d/{sqid}/export.pdf" download> needs no JavaScript, and the whole read view is built on the guarantee that this screen works without it (TestShowRendersContentWithoutScript). Moving export into a JS-only menu breaks that silently — the menu simply would not open, and the only route to a person's own document would be gone. <details> opens with no script at all.
  • An honest disclosure beats a half-built menu. <summary> is a real disclosure control that browsers and assistive technology already handle. Declaring role="menubar" promises the full menu pattern — typeahead, submenu semantics, the lot — and anything short of all of it is worse for a screen reader user than the disclosure it replaced.
  • name="docs-menubar" gives the exclusive group for free: opening one menu closes the others, with no JavaScript and no state to keep.
  • No nested <details>, anywhere. rastrillo's rst-menus has a known trap where a nested <details> in a name= group collapses its parent. The fix is not to work around it but to have no submenus: §3.2's "Download ▸", "Text ▸", "Font ▸" and the rest flatten into sibling items. Fewer levels is the better menu regardless; the submenus were Google Docs' shape copied rather than chosen.

JavaScript then adds, and never enables: Left/Right between summaries, Up/Down within an open menu, Escape closes and returns focus to its summary, Tab leaves the bar, and a roving tabindex across the summaries — reusing toolbar-roving.mjs rather than a second implementation — so the bar is one tab stop.

  • Every item shows its shortcut, and the shortcut works whether or not the menu was ever opened.
  • 24×24 minimum targets. The existing [docs-tool] is 28px and clears this; menu items are full-width rows and clear it comfortably.
  • At 320px the bar wraps. It does not scroll and it does not collapse. Six menu titles do not fit 320px, so flex-wrap puts them on two lines and every one stays reachable.

This originally specified collapsing to a single Menu button opening one role="menu" whose items were the six menus as submenus — a design that contradicted this same section's rule against nested <details>, which is the rst-menus trap the Sheets session hit first. Wrapping keeps ONE component in ONE arrangement: same markup, same command table, same keyboard behaviour at every width, nothing changing shape at a breakpoint. The only thing the media query moves is the panel, which spans the bar below 30rem rather than hanging off its own title, because an anchored 14rem panel overflows a 320px viewport — and the page gaps, whose negative margin has to match the page padding it is pulling out of.

The reflow check must be green with the menu bar present, and extended, never relaxed.

3.5 Visual direction

The design system is rastrillo's and this screen already has a settled voice: a dark chrome bar over a light page, rst-* attribute grammar, --rst-* tokens. The menu bar is not an opportunity for a new visual identity; it is an opportunity to make the chrome read as one object instead of three.

  • The bar is the same surface as the formatting bar directly below it, separated by a hairline --rst-line, not by a gap. Chrome is one slab; the paper is the figure.
  • Menu titles are plain text at body size, no caps, no tracking. The only weight change is on the open menu.
  • The open menu is a --rst-surface panel with the design system's own radius and one shadow level. It does not animate in. Motion here would be decoration on a control a person opens fifty times a day.
  • Disabled items are --rst-text-faint, the token the toolbar already uses for a disabled control, and they stay in place rather than disappearing.

4. Selected states — done

[docs-tool][aria-pressed="true"] drew a 2px box-shadow: inset 0 -2px 0 0 under the glyph. Inside a box with border-radius, a one-sided rule follows the curve rather than stopping at the corner, so the underline turned up at both ends — Paul's "weird rounded one-side", and a real defect rather than a preference: the pressed button read as a tab stem, not a selected control.

Fixed to box-shadow: inset 0 0 0 1px currentColor — a complete ring on the same radius. Still box-shadow rather than border, for the reason the original comment gave and which matters more now: it costs no layout, so it cannot shrink the 28px target or widen the bar, and a real border on nine buttons would have spent 18px of the 320px budget to say the same thing. The non-colour signal §10.3 requires is preserved: the ring is present or absent independent of hue.

5. Searchable selects

The three toolbar selects ([docs-tool-select]: block, font, size) are hand-styled native <select>s. They become design-system comboboxes by opting in — rst-input, data-rst-select, and the three data-rst-select-* catalog strings field-select.html emits.

Read select.js before designing against it. Two things this spec asserted in its first draft are false, and both were checked only because they were about to be built on:

  • There is no ten-option threshold in select.js. That judgement lives entirely in field-select.html, which decides whether to emit the opt-in. Once a <select> carries data-rst-select, it is enhanced and it always gets a filter input. There is no suppression to rely on.
  • select.js does not handle disabled. The string does not appear in it. It mirrors value, options, <optgroup>s, required and reset — not the disabled state.

Four things this must get right, none of which the opt-in gives for free:

  1. select.js builds an [rst-combo] wrapper containing a text input[role=combobox] and a ul[rst-combo-list] — a wider, input-shaped control where a compact select used to be, with width: 100% on the input. The max-inline-size: 11rem cap the selects carry has to move to the wrapper, or the bar grows and §3.4 fails.
  2. The three selects are disabled until the editor mounts, and the enhancement will not honour that. Left alone, this ships a control that looks usable, accepts focus and typing, and drives a disabled select behind it. We mirror it ourselves: the enhancement runs, then toolbar.mjs enables or disables both halves together when it enables the bar. This is ours to own; it is not a framework bug to report.
  3. The combobox is a text input, and the editor needs focus back. Choosing a size must return the caret to the document, or a person sets 14pt and types into the font filter. Every existing toolbar control is a <button>, which does not have this problem; this is the first control in the bar that holds a caret of its own.
  4. The roving tabindex has to learn about it. toolbar-roving.mjs collects controls under the toolbar root; the native <select> gets tabindex="-1" and aria-hidden="true" from the enhancement, and the new input is what a person actually reaches. The bar must stay one tab stop with the arrow keys moving across nine buttons and three comboboxes.

All three selects opt in, and the framework's own comment disagrees with that. field-select.html says search below ten options is "furniture, not help", and font has three options and block has four. Opting in all three is Paul's explicit instruction, and it is also the better call for a reason the framework's threshold cannot see: these three sit side by side in one group, and one combobox flanked by two native selects looks worse than either extreme. All three are also growth surfaces — §6 takes size to eleven immediately, spec §11.2 commits to named styles in the block list, and the face list is designed to grow. The tension is recorded rather than resolved silently: if the filter over three fonts reads as silly in the morning, the fix is one attribute, not a redesign.

6. Font size

Today: four named steps (normal, sm, lg, xl), stored as three attribute-free marks. Paul asked for font size, which in every word processor means points.

Design: a closed enumeration of point-size marks. size_8, size_9, size_10, size_11, size_12, size_14, size_18, size_24, size_36, size_48, size_72 — Google Docs' own ladder. Each is an attribute-free mark emitting a fixed class from a literal row in MARKS, exactly as the existing three do.

This is the whole of §1.3's reasoning applied: eleven more rows of the same discipline, and no new safety decision. A mark carrying "14pt" would be a valued mark, would put a document byte in attribute position, and would need Plan 7's escaping work first.

What it touches, and what it costs:

  • core/src/fragment.rs — eleven MARKS rows; the mark-ordering rule (size marks ahead of semantic ones) already covers them.
  • fixtures/editor-entry.js — eleven schema marks with excludes so a run is at one size.
  • fixtures/toolbar.mjs — SIZE_MARKS becomes the eleven; the exclusive-set logic is unchanged.
  • internal/docs/export.go — Sizes becomes eleven options, 12 first, because a <select> shows its first option until a script sets one and 12pt is what an unmarked run is. This is the same mistake the existing comment records having already made once with Small.
  • views/show.html — eleven docs-size-* classes at real point sizes.
  • internal/export/pdf.go — sizeScale becomes a point lookup rather than a multiplier ladder.
  • internal/export/markdown.go — unchanged; Markdown has no sizes.
  • Migration. The four old marks must keep rendering. size_sm, size_lg, size_xl stay in MARKS permanently, mapped to their nearest point class. Documents in the wild carry them, and dropping a mark from MARKS is silent data loss by §1.3's own definition. They simply stop being offered.
  • Wire fixtures regenerate; wasm rebuilds; wire_compat covers it.

7. The Tab key

7.1 What Tab does, by context

| Context | Tab | Shift+Tab | |---|---|---| | In a list item, at its start | Demote (nest one level) | Promote | | In a list item, elsewhere | Demote | Promote | | Empty paragraph, caret at start | Indent the paragraph | Outdent | | Paragraph, caret at start | Indent the paragraph | Outdent | | Paragraph, caret mid-text | Insert a tab | Outdent | | In a table (when tables land) | Next cell | Previous cell |

The list cases use sinkListItem/liftListItem from prosemirror-schema-list, which the bundle already imports half of.

7.2 Two things Tab must not break

  • Tab must not trap keyboard focus. WCAG 2.1.2. Inside the editable surface Tab indents; that is the standard editor exception and it is only acceptable because Escape-then-Tab leaves, and because the toolbar and menu bar are reachable. The existing comment at editor-entry.js:669 already worries about tabbing in from the sign-out button; this must not regress it.
  • A literal tab character must survive the whole pipeline. It goes into the CRDT as text, so fragment.rs needs no change — but HTML collapses it, so [docs-text] needs tab-size and the run must not be white-space: normal. The PDF exporter measures text and must advance to the next stop rather than drawing a glyph. Untested, this is a tab that looks right on screen and vanishes in export.

7.3 Paragraph indent, and why tab stops are not built tonight

Indent is a block attribute, indent: 0..8, clamped to an integer exactly as heading.level already is — so it spends none of §1.3's property. It renders as a docs-indent-N class from a closed table, like ALIGNMENTS.

Ruler tab stops are specified but not built. They need a ruler widget, per-paragraph stop positions as a list of numbers on a block attribute, and an exporter that honours them. The list-of-numbers part is the tell: it is the first document-supplied structure rather than a clamped scalar, and it deserves its own plan rather than an hour at 3am. The View menu carries "Show ruler" disabled to mark the hole.

8. Autoformat — detecting bullets

prosemirror-inputrules is not currently imported. Rules to add:

| Typed | Becomes | |---|---| | - * + | Bullet list | | 1. (any number) | Ordered list, starting at that number | | > | Blockquote | | # … ###### | Heading 1–6 (clamped to the 1–3 the block select offers) | | --- | Horizontal rule | | ` | Code block (only if a code block node exists; it does not — omit) |

Undo is the whole feature. Autoformat that cannot be undone is infuriating rather than helpful. undoInputRule bound to Backspace immediately after a rule fires reverts the transformation and leaves the typed characters — this is prosemirror-inputrules' own undoInputRule, and it must be bound, or every accidental - becomes a list the person has to fight.

Interaction with y-prosemirror's undo: the input rule and its undo are a single ProseMirror transaction each, so yUndoPlugin treats them atomically. This needs a test, not an assumption — the existing bundle's undo is y-prosemirror's, not prosemirror-history's, and undoInputRule is written against the latter's assumptions.

Smart quotes and em-dash substitution are not included: they are the autoformat people most often want off, and there is no preferences surface to turn them off in.

9. Pages — infinite by default, paged on request

9.1 What paged means when the CRDT stores a flat block sequence

Nothing about pagination is persisted, and nothing about it is shared. Page breaks are a view computed in the browser from measured block heights against the page box. Two people editing the same document at different zoom levels see different page breaks and that is correct — the document is the block sequence; pages are a rendering of it.

This is the decision that keeps pagination out of the CRDT, and it follows from concurrency: a persisted page break would be an edit, would merge, and two people typing would fight over where page 3 starts.

The consequence to state plainly: screen pagination and PDF pagination are computed by different code (browser layout vs internal/export/pdf.go) and will not always agree. show.html's existing comment already claims they agree because both measure with the same font programs; that claim is true for line breaking and is not true for page breaking unless the page box, margins and widow/orphan rules match exactly. Either they are made to match, or the claim comes out of the comment. It must not be left asserted and untrue — §10.3's own failure mode, and the one this repo has already been bitten by.

9.2 The toggle

View → Paged / View → Infinite, a pair of <button>s carrying aria-pressed — not menuitemradio, for §3.3's reason: these menus are disclosures and their items are ordinary buttons. It is a per-person preference, not a document property — how you like to read is not something you impose on a collaborator. Stored in a cookie, defaulting to infinite.

A cookie rather than the localStorage this first specified, and the reason is in the same sentence that specified it: "applied before first paint". localStorage is invisible to the server, so the mode could only be applied once the deferred bundle ran — which is a document visibly reflowing from infinite into pages a moment after it appears. The server reads the cookie and renders data-view on the canvas, so the first paint is already right.

Infinite is one [docs-page] box that grows. Paged is the SAME single box, with a desk-coloured gap drawn between pages as a widget decoration inside the text flow.

Not one [docs-page] per page, which is what this said first and what show.html's own comment anticipated ("one box, its own padding, ready to hold more than one sibling"). ProseMirror owns everything inside the editable element ({mount: el}), so its children cannot be wrapped in sibling page boxes without taking the document away from it. Repeating the box is a layout-engine shape; a decoration is a pagination-as-a-view shape, which is what §9.1 already committed to. show.html's comment is corrected in the same commit.

9.3 Explicit page breaks

Insert → Page break is a real node (page_break), a MARKS-style literal in fragment.rs, rendering as an empty block. In infinite mode it draws as a labelled rule; in paged mode it forces a break; in PDF it forces a break. It is the only part of pagination that is in the document, and it is legitimate because the author asked for it.

10. Links

Implemented exactly as docs/superpowers/plans/2026-09-02-images-links-tables.md specifies — that plan is written, Fable-reviewed and revised, and this spec adopts it rather than restating it. The four points most easily got wrong, recorded because getting any of them wrong is a security bug:

  1. The scheme allowlist runs in the shared path, not in node_html. A rejected href means the run is emitted with no link mark at all, in fragment_json and text_json as much as in fragment_html. Otherwise markdown.go emits [text](javascript:…) and the PDF's /Link annotation carries the same.
  2. Positive grammar, not a denylist: ^[A-Za-z][A-Za-z0-9+.-]*: with the scheme, case-folded, in {http, https, mailto}. Everything else renders as text — including the no-colon case, which a control-character denylist misses entirely.
  3. internal/export/document.go has to grow. Mark is {Type string} and Attrs is closed to Level/Align; both widen, and text_json must emit mark attrs, or no exporter can see the href.
  4. The wire fixture is generated, not hand-built. A mark's wire value is an attrs object under the mark name — a shape no existing fixture has. Generate it through y-prosemirror's own prosemirrorToYXmlFragment so Rust is tested against the real encoding rather than a transcription of it.

rel="noreferrer", not noopener — the plan sets no target, and noopener does nothing without one.

UI: Insert → Link (Ctrl-K conflicts with the existing jump-to palette — use Ctrl-Shift-K for link, and say so in the shortcuts sheet, rather than stealing a binding people already use). insert-link and remove-link are the command names, agreed with the session that owns the rest of Plan 7 so insert-image and insert-table drop into the same table later.

11. The remaining basics

  • Word count — characters, words, paragraphs; whole document and selection. A Tools/View item opening a small panel. Counting is over ProseMirror's document, in the browser. Word segmentation uses Intl.Segmenter where available, because splitting on whitespace is wrong for most of the twelve locales.
  • Find and replace — in-document, over ProseMirror, with a decoration for matches. Distinct from the existing cross-document search (search.go), and must not be confused with it in copy: that one finds documents, this one finds text in this document.
  • Print — window.print() plus a print stylesheet that hides all chrome, forces paged geometry regardless of the view toggle, and sets real page margins. Not a PDF re-implementation.
  • Page numbers — in paged mode and in print/PDF only. Not stored.
  • Keyboard shortcuts sheet — a Tools item listing every binding, generated from the command table rather than hand-maintained, so it cannot drift.
  • Clear formatting — removes every mark in the exclusive groups and resets the block to paragraph. Cheap, and the thing people reach for after a bad paste.

12. Document tabs — specified, built later

Google Docs' tabs are named sections in a left rail, each its own sub-document, reorderable and nestable one level.

The shape, so the later plan does not have to reopen it: a document gains an ordered list of tabs; each tab owns its own Yjs XmlFragment under one Yjs document, keyed by tab id. One document, one update log, one snapshot — so sharing, sync and ownership are unchanged. fragment.rs gains a fragment-selecting entry point; Store.HTML renders the tab asked for; the read view renders the first.

It is not built tonight because it changes the document model, every export path, the read view and the URL space at once, and because nothing else in this spec depends on it. It is written down because several decisions above — the View menu's shape, Store.HTML's signature — are cheaper to make now in a way that does not preclude it.

13. Decomposition

Ordered by dependency. Branches within a group are parallel-safe; groups are serial. The file that forces most of the serialisation is views/show.html, which every UI change touches.

| # | Branch | Touches | Depends on | |---|---|---|---| | 1 | Selected states + searchable selects | show.html (CSS only) | — | | 2 | Font size enumeration | fragment.rs, editor-entry.js, toolbar.mjs, export.go, pdf.go, fixtures, wasm | — | | 3 | Tab key + indent | editor-entry.js, fragment.rs (indent table), pdf.go | 2 (both touch fragment.rs) | | 4 | Autoformat input rules | editor-entry.js | 3 (same keymap) | | 5 | Menu bar | show.html (heavily), render.go, export.go, new fixtures/menu.mjs | 1 | | 6 | Pages: infinite/paged + page break | show.html, fragment.rs, pdf.go, new fixtures/pages.mjs | 5 (View menu), 2 | | 7 | Links | fragment.rs, document.go, markdown.go, pdf.go, editor-entry.js, fixtures | 2, 5 (Insert menu) | | 8 | Word count, find/replace, print, shortcuts | show.html, new fixtures | 5 |

Branches 1 and 2 start immediately and in parallel. Everything else queues behind them.

fragment.rs is the contended file — branches 2, 3, 6 and 7 all change MARKS or a node table, and each regenerates wire fixtures and the wasm. They must land one at a time, rebasing, and each must rebuild-and-commit the wasm in one step (§1.5).

14. The five things most likely to go wrong

  1. The wasm/bundle gate deletes work. On failure it runs git clean -fd over internal/docs/static/. Guard: rebuild and commit as one step, every time, and run the gate locally before pushing.
  2. A mark or node reaches fragment.rs unhandled and renders as nothing — the module's own "silence here is data loss". Guard: every new schema element gets a MARKS/node-table row and a generated wire fixture in the same commit as the schema change.
  3. 320px regresses under the menu bar and nobody sees it, because Chromium will not start in the sandbox. Guard: the reflow check is extended, not relaxed, and the collapsed arrangement is checked too; if it cannot be run, that is stated rather than assumed passing.
  4. Autoformat undo fights y-prosemirror's undo, because undoInputRule is written against prosemirror-history. Guard: a test that types - , presses Backspace, and asserts the list is gone and the characters remain — before the rules ship.
  5. Screen and PDF pagination disagree while a comment asserts they agree. Guard: either make the page boxes match, or delete the claim. Nothing stays asserted and untrue.