| 1 | # Word-processor basics — menus, tabs, autoformat, sizes and pages |
| 2 | |
| 3 | Brief, from Paul, 2026-09-02, going into an unattended overnight run: |
| 4 | *"implement a full-stack of the expected basic features that our word |
| 5 | processor should have. Starting with things like: tabs, detecting |
| 6 | bullets, font-size, a menu system with e.g. Export rather than random |
| 7 | buttons... fix these selected states to just have simple borders not the |
| 8 | weird rounded one-side... selects should be Rastrillo design system |
| 9 | searchable selects... multiple pages, and the ability to switch between |
| 10 | paged and infinite mode... anything else that a Fable brainstorm |
| 11 | recommends for a maximal viable Docs app."* |
| 12 | |
| 13 | Four things were settled by Paul before he signed off, and are not |
| 14 | reopened here: |
| 15 | |
| 16 | - **Base.** Work stacks on `origin/main` (7507a33). See §1.1 — the |
| 17 | first thing this spec got wrong. |
| 18 | - **"Tabs" means both.** The Tab *key* (indent, list demote, |
| 19 | Shift+Tab outdent) is built now. Google-Docs-style **document |
| 20 | tabs** — named sections in a rail, each its own sub-document — are |
| 21 | specified here (§12) and built later. |
| 22 | - **The menu system is a full menu bar**, File / Edit / View / Insert / |
| 23 | Format / Tools, above the toolbar. It replaces the two export |
| 24 | buttons currently pinned to the formatting bar. |
| 25 | - **Pages are infinite by default**, paged on request. |
| 26 | |
| 27 | ## 1. What is actually there, and what constrains it |
| 28 | |
| 29 | ### 1.1 The base, and the mistake that nearly cost an hour |
| 30 | |
| 31 | This spec's first draft was written against a local `main` ref that had |
| 32 | not been fetched, and concluded that 39 commits of finished work — the |
| 33 | toolbar, fonts, PDF and Markdown export, alignment, the sans default, |
| 34 | live sync, search, snapshots, mobile margins — were unlanded and needed |
| 35 | taking through the gate. All of it was already on `origin/main`. The |
| 36 | peer session doing discussions triage caught it within minutes. |
| 37 | |
| 38 | This is not a new lesson; it is the *second* entry under AGENTS.md's |
| 39 | "Watch for tools that answer wrongly instead of failing", verbatim: |
| 40 | *"Citing your working tree instead of `origin/main`. A checkout parked |
| 41 | on a feature branch gives you true statements about the wrong tree."* |
| 42 | The check that would have caught it is one command — `git fetch origin` |
| 43 | before `git rev-list --count origin/main..<branch>` — and the reason it |
| 44 | was skipped is that `git log` on the main checkout *answered*, fluently |
| 45 | and wrongly, instead of failing. |
| 46 | |
| 47 | It is recorded here rather than in a commit message because the same |
| 48 | trap is sitting in front of whoever implements this spec: several |
| 49 | branches (`worktree-import-documents`, the `*-squashed` family) are |
| 50 | still ahead of `origin/main` tonight, and the same wrong reading is |
| 51 | available at every one of them. |
| 52 | |
| 53 | ### 1.2 The editor today |
| 54 | |
| 55 | `origin/main` at 7507a33 is a genuinely working editor: ProseMirror in |
| 56 | the browser over a Yjs document, a Rust CRDT core (`core/`) compiled to |
| 57 | wasm and run server-side under wazero, and a server-rendered read view |
| 58 | that works with scripts disabled. |
| 59 | |
| 60 | Already shipped, and **not** to be rebuilt: |
| 61 | |
| 62 | | Capability | Where | |
| 63 | |---|---| |
| 64 | | Undo/redo, bold/italic/code, block type (¶, H1–H3) | `fixtures/toolbar.mjs` | |
| 65 | | Alignment (left/centre/right) | `fixtures/align.mjs`, `ALIGNMENTS` in `fragment.rs` | |
| 66 | | Font face (sans/serif/mono), 12 embedded faces | `fixtures/fonts.mjs`, `internal/fonts` | |
| 67 | | Size, as four named steps | `size_sm`/`size_lg`/`size_xl` marks | |
| 68 | | Bullet and ordered lists (by button) | `wrapInList` in `toolbar.mjs` | |
| 69 | | PDF and Markdown export | `internal/export`, `internal/docs/export.go` | |
| 70 | | Live sync across tabs (250ms) | `fixtures/live-updates.mjs` | |
| 71 | | Search, and Ctrl-K jump-to | `internal/docs/search.go`, `fixtures/jump-to.mjs` | |
| 72 | | Snapshots, so a document stops going read-only | `internal/core` | |
| 73 | | Paste fidelity from Google Docs | `fixtures/paste.mjs` | |
| 74 | | Roving-tabindex toolbar, ARIA toolbar semantics | `fixtures/toolbar-roving.mjs` | |
| 75 | |
| 76 | Genuinely absent, and therefore this spec's actual scope: **no `Tab` |
| 77 | binding anywhere** (`editor-entry.js`'s keymap is `Mod-z/y/b/i` plus |
| 78 | `baseKeymap`), **no `inputRules` import at all**, no menu, no |
| 79 | pagination, and no link mark. |
| 80 | |
| 81 | ### 1.3 The invariant everything else is subordinate to |
| 82 | |
| 83 | `core/src/fragment.rs` renders the CRDT to HTML. Its output is returned |
| 84 | as `template.HTML` by `Store.HTML` and written **unescaped** by |
| 85 | `views/show.html`. That is safe today because of one property the module |
| 86 | states and maintains: |
| 87 | |
| 88 | > Every one of these is attribute-free on the CRDT side — the mark's |
| 89 | > presence is the whole of its meaning — and the markup each one emits |
| 90 | > is a compile-time constant chosen by lookup from this table... **A |
| 91 | > mark carrying a value — `link`'s href, or a `fontFamily` naming its |
| 92 | > own face — would break that**, and `POST /d/{sqid}/updates` accepts |
| 93 | > arbitrary client-supplied Yjs bytes that never went through |
| 94 | > ProseMirror's schema, so the value would be genuinely arbitrary. |
| 95 | |
| 96 | Two consequences that decide two of this spec's features: |
| 97 | |
| 98 | 1. **Font size cannot be a valued mark** (§6). A mark carrying `"14pt"` |
| 99 | puts a document-supplied byte into attribute position. The |
| 100 | enumeration in §6 is not a stylistic preference; it is what keeps |
| 101 | this property. |
| 102 | 2. **Links spend the property deliberately** (§10), and therefore |
| 103 | inherit Plan 7's scheme allowlist in full. That plan |
| 104 | (`docs/superpowers/plans/2026-09-02-images-links-tables.md`) is |
| 105 | already written and Fable-reviewed. §10 does not restate it — it |
| 106 | commits to implementing it as written. |
| 107 | |
| 108 | Anything the toolbar or a menu can produce that `fragment.rs` cannot |
| 109 | round-trip is silent data loss, which is the module comment's own |
| 110 | phrase for it. Every feature below states what it costs in `MARKS`, |
| 111 | `ALIGNMENTS`, node names, wire fixtures and the wasm rebuild. |
| 112 | |
| 113 | ### 1.4 Who else is in this repo tonight |
| 114 | |
| 115 | Three sessions are working in parallel, and the boundaries were agreed |
| 116 | directly rather than assumed: |
| 117 | |
| 118 | - **`fixtures/browser-drive.mjs` is not ours tonight.** The discussions- |
| 119 | triage session owns it and is fixing a live WCAG 2.2 SC 1.4.10 |
| 120 | failure with it: `list.html`'s header is `scrollWidth` 356 against a |
| 121 | 320px viewport, offenders `button` 356, `button` 350, |
| 122 | `form[docs-search-form]` 356. New browser checks go in their own file |
| 123 | and are folded in afterwards. |
| 124 | - **The 320px reflow check must stay green and must not be weakened.** |
| 125 | The menu bar lands in a page header, which is exactly where that |
| 126 | failure lives. §3.4 is not decoration. |
| 127 | - **Links are ours; images and tables are not.** The paste session |
| 128 | confirmed it is not implementing Plan 7 tonight. Images are blocked |
| 129 | on a sealing-key decision from Paul regardless. Tables are unclaimed |
| 130 | and out of scope here. |
| 131 | |
| 132 | ### 1.5 Two facts about the toolchain, learned the expensive way |
| 133 | |
| 134 | - **Chromium cannot start inside the sandbox** (`scripts/browser-drive.sh`: |
| 135 | "socket() failed: Operation not permitted"). Anything needing a real |
| 136 | browser runs outside it or goes to Paul. |
| 137 | - **The gate's bundle check requires the rebuilt bundle to be |
| 138 | *committed*, not merely rebuilt**, and on failure it runs |
| 139 | `git checkout --` and `git clean -fd` over `internal/docs/static/`, |
| 140 | deleting the bundle just built. Rebuild and commit in one step. The |
| 141 | gate also needs a writable Go cache: `GOCACHE` must point somewhere |
| 142 | writable, since `~/.cache/go-build` is read-only under the sandbox. |
| 143 | |
| 144 | ## 2. Rastrillo: the upgrade Paul expected is not the one we need |
| 145 | |
| 146 | Paul's brief says searchable selects "might need to get the latest |
| 147 | Rastrillo". Checked, and the condition is false: **`ui/select.js` is |
| 148 | byte-identical between the pinned v0.22.0 and the latest v0.25.0** |
| 149 | (11,919 bytes, both). More than that, `internal/docs/render.go` already |
| 150 | serves it — `vendoredStaticFS` maps `static/select.js` to |
| 151 | `ui.SelectJS()`, and the shell's `<head>` links it unconditionally. |
| 152 | |
| 153 | So the searchable select needs **no upgrade at all**, only the |
| 154 | `data-rst-select` opt-in on the three toolbar selects (§5). |
| 155 | |
| 156 | The upgrade was nevertheless attempted, to see what it costs: the module |
| 157 | path changes to `amadan.net/rastrillo/rastrillo`, ten Go files change |
| 158 | their import line, and all six packages pass. It is *safe*, but it |
| 159 | carries a 138-line `tokens.css` change that nothing in this repo can |
| 160 | visually verify tonight (§1.5 — no browser in the sandbox), and it |
| 161 | would collide, repo-wide and trivially, with every branch in flight. |
| 162 | |
| 163 | **Ruling: the upgrade does not ride along with this work.** It is its |
| 164 | own change, on its own branch, merged only once someone has looked at |
| 165 | the result in a browser. Bundling an unverifiable visual change into a |
| 166 | feature push nobody is awake to check is how a night's work becomes a |
| 167 | morning's bisect. |
| 168 | |
| 169 | ## 3. The menu bar |
| 170 | |
| 171 | ### 3.1 What it replaces |
| 172 | |
| 173 | Two ghost buttons, `PDF` and `MD`, pinned to the trailing end of the |
| 174 | formatting bar (`show.html`'s `[docs-doc-actions]`). They are Paul's |
| 175 | "random buttons": export is a File-menu verb everywhere a person has |
| 176 | met one, and putting it in the formatting bar puts a document-level |
| 177 | action inside a run-level control group. |
| 178 | |
| 179 | ### 3.2 Contents |
| 180 | |
| 181 | **What of this actually shipped, since the list below is the plan and |
| 182 | not the record.** Built and reachable: the whole bar; File's New |
| 183 | document, Open, both downloads, Print and Sign out; Edit's Undo, Redo |
| 184 | and Select all; View's Paged/Infinite pair; Format's marks, paragraph |
| 185 | styles, alignments, lists, quote, indent/outdent and Clear formatting; |
| 186 | Tools' Word count and Keyboard shortcuts. Rendered and greyed, each |
| 187 | naming the section it waits on: Insert's Link, Image, Table, Page break |
| 188 | and Horizontal line, Edit's Find and replace, View's Show ruler. |
| 189 | |
| 190 | **Not built and not in the menu at all**: Rename, Make a copy, Page |
| 191 | setup, Cut/Copy/Paste, Full screen, Special characters, Spelling. The |
| 192 | list below still names them because it is the design; they are called |
| 193 | out here so nobody reads an unmarked line as a promise that was kept. |
| 194 | Cut/Copy/Paste in particular should not be built — a `<button>` cannot |
| 195 | drive the clipboard reliably (`execCommand` is deprecated, |
| 196 | `navigator.clipboard.read` is permission-gated and absent in Firefox), |
| 197 | and a menu item that sometimes fails is worse than one that is not |
| 198 | there. |
| 199 | |
| 200 | Real tonight unless marked. Items that are present-but-disabled are |
| 201 | listed because a menu that hides what the app will obviously grow is a |
| 202 | menu that gets redesigned; one that greys it is a menu that gets filled |
| 203 | in. Nothing is listed that has no plan behind it. |
| 204 | |
| 205 | Submenus are flattened rather than nested, for the reason §3.4 gives. |
| 206 | |
| 207 | **File** — New document · Open (goes to the list) · Rename · Make a |
| 208 | copy · *divider* · Download as PDF · Download as Markdown · Print · |
| 209 | *divider* · Page setup · *divider* · Sign out |
| 210 | |
| 211 | **Edit** — Undo · Redo · *divider* · Cut · Copy · Paste (the three are |
| 212 | present and route to the browser's own clipboard behaviour, with their |
| 213 | real shortcuts shown; they are not re-implemented) · *divider* · Find |
| 214 | and replace · *divider* · Select all |
| 215 | |
| 216 | **View** — Paged / Infinite (a checked pair, §8) · Show ruler |
| 217 | (*disabled*, arrives with tab stops, §7.3) · *divider* · Word count · |
| 218 | *divider* · Full screen |
| 219 | |
| 220 | **Insert** — Link · Image (*disabled*, Plan 7, blocked on the sealing |
| 221 | key) · Table (*disabled*, Plan 7) · *divider* · Page break (§9.3) · |
| 222 | Horizontal rule · *divider* · Special characters (*disabled*) |
| 223 | |
| 224 | **Format** — Bold · Italic · Code · *divider* · Normal text · |
| 225 | Heading 1 · Heading 2 · Heading 3 · *divider* · Align left · Align |
| 226 | centre · Align right · *divider* · Bulleted list · Numbered list · |
| 227 | Indent · Outdent · *divider* · Clear formatting |
| 228 | |
| 229 | Font and Size are deliberately **not** in the Format menu. They are the |
| 230 | two controls whose current value has to be *visible* — a person needs to |
| 231 | see that they are in 12pt Sans without opening anything — and a menu |
| 232 | item cannot show that the way the toolbar's select does. Duplicating |
| 233 | them as eleven point sizes and three faces would also make Format the |
| 234 | longest menu in the app to say what the bar beside it already says. |
| 235 | |
| 236 | **Tools** — Word count · *divider* · Keyboard shortcuts · *divider* · |
| 237 | Spelling (*disabled* — the browser's own spellcheck is on; this item |
| 238 | would be a second, worse one) |
| 239 | |
| 240 | Every string is a catalog key in `render.go`'s `Catalog`. There are |
| 241 | roughly fifty. A hardcoded English string is a bug (AGENTS.md). |
| 242 | |
| 243 | ### 3.3 The two-ways-to-do-everything problem |
| 244 | |
| 245 | A menu bar over an existing toolbar duplicates most of the toolbar. That |
| 246 | is correct and deliberate — it is what every word processor does — but |
| 247 | it has to be *principled*, or the two drift: |
| 248 | |
| 249 | - **One command table, two surfaces.** `fixtures/toolbar.mjs` already |
| 250 | owns the command table (`data-docs-command` resolved against named |
| 251 | commands, held honest by |
| 252 | `TestToolbarOptionValuesMatchTheBundle`, which greps this file's |
| 253 | source for the literal name). The menu dispatches into the **same** |
| 254 | table by the **same** names. A menu item with no command is a test |
| 255 | failure, not a silent no-op. |
| 256 | - **The toolbar keeps only what benefits from being one click away** |
| 257 | and from showing state: undo/redo, block, font, size, B/I/code, |
| 258 | align, lists. The menu carries everything, including those. |
| 259 | - **State flows one way.** The ProseMirror plugin that already keeps |
| 260 | toolbar `aria-pressed` in step (`toolbar.mjs`'s plugin, updating on |
| 261 | every selection change including remote ones) also sets menu items' |
| 262 | `aria-pressed`. Nothing listens for clicks to infer state. |
| 263 | |
| 264 | `aria-pressed`, not the `aria-checked` this said first: these menus |
| 265 | are disclosures whose items are ordinary `<button>`s, and |
| 266 | `aria-checked` belongs to `menuitemcheckbox` and `menuitemradio`. |
| 267 | Putting an attribute on an element it does not apply to is worse than |
| 268 | putting none on at all. |
| 269 | |
| 270 | ### 3.4 Keyboard, ARIA, and 320px |
| 271 | |
| 272 | The app's rule is WCAG 2.2 AA with no exemptions, so this is a |
| 273 | requirement list, not a wish list. |
| 274 | |
| 275 | **The menu is `<details>`/`<summary>`, and stays a disclosure.** This |
| 276 | reverses what this section said in its first draft, which was |
| 277 | `role="menubar"` with `menuitem` children and a JS-driven roving |
| 278 | tabindex. The Sheets session — building the same chrome tonight — asked |
| 279 | which of the two we had picked, and checking the answer showed the |
| 280 | menubar version was a regression: |
| 281 | |
| 282 | - **The export links work today with scripts disabled.** |
| 283 | `<a href="/d/{sqid}/export.pdf" download>` needs no JavaScript, and |
| 284 | the whole read view is built on the guarantee that this screen works |
| 285 | without it (`TestShowRendersContentWithoutScript`). Moving export |
| 286 | into a JS-only menu breaks that silently — the menu simply would not |
| 287 | open, and the only route to a person's own document would be gone. |
| 288 | `<details>` opens with no script at all. |
| 289 | - **An honest disclosure beats a half-built menu.** `<summary>` is a |
| 290 | real disclosure control that browsers and assistive technology |
| 291 | already handle. Declaring `role="menubar"` promises the full menu |
| 292 | pattern — typeahead, submenu semantics, the lot — and anything short |
| 293 | of all of it is worse for a screen reader user than the disclosure it |
| 294 | replaced. |
| 295 | - **`name="docs-menubar"` gives the exclusive group for free**: opening |
| 296 | one menu closes the others, with no JavaScript and no state to keep. |
| 297 | - **No nested `<details>`, anywhere.** rastrillo's `rst-menus` has a |
| 298 | known trap where a nested `<details>` in a `name=` group collapses |
| 299 | its parent. The fix is not to work around it but to have no submenus: |
| 300 | §3.2's "Download ▸", "Text ▸", "Font ▸" and the rest flatten into |
| 301 | sibling items. Fewer levels is the better menu regardless; the |
| 302 | submenus were Google Docs' shape copied rather than chosen. |
| 303 | |
| 304 | JavaScript then *adds*, and never enables: Left/Right between summaries, |
| 305 | Up/Down within an open menu, Escape closes and returns focus to its |
| 306 | summary, Tab leaves the bar, and a **roving tabindex** across the |
| 307 | summaries — reusing `toolbar-roving.mjs` rather than a second |
| 308 | implementation — so the bar is one tab stop. |
| 309 | - Every item shows its shortcut, and the shortcut works whether or not |
| 310 | the menu was ever opened. |
| 311 | - 24×24 minimum targets. The existing `[docs-tool]` is 28px and clears |
| 312 | this; menu items are full-width rows and clear it comfortably. |
| 313 | - **At 320px the bar wraps. It does not scroll and it does not |
| 314 | collapse.** Six menu titles do not fit 320px, so `flex-wrap` puts |
| 315 | them on two lines and every one stays reachable. |
| 316 | |
| 317 | This originally specified collapsing to a single `Menu` button |
| 318 | opening one `role="menu"` whose items were the six menus as |
| 319 | submenus — a design that contradicted this same section's rule |
| 320 | against nested `<details>`, which is the `rst-menus` trap the Sheets |
| 321 | session hit first. Wrapping keeps ONE component in ONE arrangement: |
| 322 | same markup, same command table, same keyboard behaviour at every |
| 323 | width, nothing changing shape at a breakpoint. The only thing the |
| 324 | media query moves is the panel, which spans the bar below 30rem |
| 325 | rather than hanging off its own title, because an anchored 14rem |
| 326 | panel overflows a 320px viewport — and the page gaps, whose negative |
| 327 | margin has to match the page padding it is pulling out of. |
| 328 | |
| 329 | The reflow check must be green with the menu bar present, and |
| 330 | extended, never relaxed. |
| 331 | |
| 332 | ### 3.5 Visual direction |
| 333 | |
| 334 | The design system is rastrillo's and this screen already has a settled |
| 335 | voice: a dark chrome bar over a light page, `rst-*` attribute grammar, |
| 336 | `--rst-*` tokens. The menu bar is **not** an opportunity for a new |
| 337 | visual identity; it is an opportunity to make the chrome read as one |
| 338 | object instead of three. |
| 339 | |
| 340 | - The bar is the same surface as the formatting bar directly below it, |
| 341 | separated by a hairline `--rst-line`, not by a gap. Chrome is one |
| 342 | slab; the paper is the figure. |
| 343 | - Menu titles are plain text at body size, no caps, no tracking. The |
| 344 | only weight change is on the open menu. |
| 345 | - The open menu is a `--rst-surface` panel with the design system's own |
| 346 | radius and one shadow level. It does not animate in. Motion here |
| 347 | would be decoration on a control a person opens fifty times a day. |
| 348 | - Disabled items are `--rst-text-faint`, the token the toolbar already |
| 349 | uses for a disabled control, and they stay in place rather than |
| 350 | disappearing. |
| 351 | |
| 352 | ## 4. Selected states — done |
| 353 | |
| 354 | `[docs-tool][aria-pressed="true"]` drew a 2px `box-shadow: inset 0 -2px |
| 355 | 0 0` under the glyph. Inside a box with `border-radius`, a one-sided |
| 356 | rule follows the curve rather than stopping at the corner, so the |
| 357 | underline turned up at both ends — Paul's "weird rounded one-side", and |
| 358 | a real defect rather than a preference: the pressed button read as a tab |
| 359 | stem, not a selected control. |
| 360 | |
| 361 | Fixed to `box-shadow: inset 0 0 0 1px currentColor` — a complete ring on |
| 362 | the same radius. Still `box-shadow` rather than `border`, for the reason |
| 363 | the original comment gave and which matters more now: it costs no |
| 364 | layout, so it cannot shrink the 28px target or widen the bar, and a real |
| 365 | border on nine buttons would have spent 18px of the 320px budget to say |
| 366 | the same thing. The non-colour signal §10.3 requires is preserved: the |
| 367 | ring is present or absent independent of hue. |
| 368 | |
| 369 | ## 5. Searchable selects |
| 370 | |
| 371 | The three toolbar selects (`[docs-tool-select]`: block, font, size) are |
| 372 | hand-styled native `<select>`s. They become design-system comboboxes by |
| 373 | opting in — `rst-input`, `data-rst-select`, and the three |
| 374 | `data-rst-select-*` catalog strings `field-select.html` emits. |
| 375 | |
| 376 | **Read `select.js` before designing against it.** Two things this spec |
| 377 | asserted in its first draft are false, and both were checked only |
| 378 | because they were about to be built on: |
| 379 | |
| 380 | - **There is no ten-option threshold in `select.js`.** That judgement |
| 381 | lives entirely in `field-select.html`, which decides whether to emit |
| 382 | the opt-in. Once a `<select>` carries `data-rst-select`, it is |
| 383 | enhanced and it **always** gets a filter input. There is no |
| 384 | suppression to rely on. |
| 385 | - **`select.js` does not handle `disabled`.** The string does not |
| 386 | appear in it. It mirrors value, options, `<optgroup>`s, `required` |
| 387 | and reset — not the disabled state. |
| 388 | |
| 389 | Four things this must get right, none of which the opt-in gives for |
| 390 | free: |
| 391 | |
| 392 | 1. **`select.js` builds an `[rst-combo]` wrapper containing a text |
| 393 | `input[role=combobox]` and a `ul[rst-combo-list]`** — a wider, |
| 394 | input-shaped control where a compact select used to be, with |
| 395 | `width: 100%` on the input. The `max-inline-size: 11rem` cap the |
| 396 | selects carry has to move to the wrapper, or the bar grows and §3.4 |
| 397 | fails. |
| 398 | 2. **The three selects are `disabled` until the editor mounts**, and |
| 399 | the enhancement will not honour that. Left alone, this ships a |
| 400 | control that looks usable, accepts focus and typing, and drives a |
| 401 | disabled select behind it. We mirror it ourselves: the enhancement |
| 402 | runs, then `toolbar.mjs` enables or disables **both** halves |
| 403 | together when it enables the bar. This is ours to own; it is not a |
| 404 | framework bug to report. |
| 405 | 3. **The combobox is a text input, and the editor needs focus back.** |
| 406 | Choosing a size must return the caret to the document, or a person |
| 407 | sets 14pt and types into the font filter. Every existing toolbar |
| 408 | control is a `<button>`, which does not have this problem; this is |
| 409 | the first control in the bar that holds a caret of its own. |
| 410 | 4. **The roving tabindex has to learn about it.** `toolbar-roving.mjs` |
| 411 | collects controls under the toolbar root; the native `<select>` gets |
| 412 | `tabindex="-1"` and `aria-hidden="true"` from the enhancement, and |
| 413 | the new input is what a person actually reaches. The bar must stay |
| 414 | one tab stop with the arrow keys moving across nine buttons and |
| 415 | three comboboxes. |
| 416 | |
| 417 | **All three selects opt in, and the framework's own comment disagrees |
| 418 | with that.** `field-select.html` says search below ten options is |
| 419 | "furniture, not help", and font has three options and block has four. |
| 420 | Opting in all three is Paul's explicit instruction, and it is also the |
| 421 | better call for a reason the framework's threshold cannot see: these |
| 422 | three sit *side by side in one group*, and one combobox flanked by two |
| 423 | native selects looks worse than either extreme. All three are also |
| 424 | growth surfaces — §6 takes size to eleven immediately, spec §11.2 |
| 425 | commits to named styles in the block list, and the face list is |
| 426 | designed to grow. The tension is recorded rather than resolved silently: |
| 427 | if the filter over three fonts reads as silly in the morning, the fix is |
| 428 | one attribute, not a redesign. |
| 429 | |
| 430 | ## 6. Font size |
| 431 | |
| 432 | Today: four named steps (`normal`, `sm`, `lg`, `xl`), stored as three |
| 433 | attribute-free marks. Paul asked for font size, which in every word |
| 434 | processor means points. |
| 435 | |
| 436 | **Design: a closed enumeration of point-size marks.** `size_8`, |
| 437 | `size_9`, `size_10`, `size_11`, `size_12`, `size_14`, `size_18`, |
| 438 | `size_24`, `size_36`, `size_48`, `size_72` — Google Docs' own ladder. |
| 439 | Each is an attribute-free mark emitting a fixed class from a literal row |
| 440 | in `MARKS`, exactly as the existing three do. |
| 441 | |
| 442 | This is the whole of §1.3's reasoning applied: eleven more rows of the |
| 443 | same discipline, and **no new safety decision**. A mark carrying `"14pt"` |
| 444 | would be a valued mark, would put a document byte in attribute position, |
| 445 | and would need Plan 7's escaping work first. |
| 446 | |
| 447 | What it touches, and what it costs: |
| 448 | |
| 449 | - `core/src/fragment.rs` — eleven `MARKS` rows; the mark-ordering rule |
| 450 | (size marks ahead of semantic ones) already covers them. |
| 451 | - `fixtures/editor-entry.js` — eleven schema marks with `excludes` so a |
| 452 | run is at one size. |
| 453 | - `fixtures/toolbar.mjs` — `SIZE_MARKS` becomes the eleven; the |
| 454 | exclusive-set logic is unchanged. |
| 455 | - `internal/docs/export.go` — `Sizes` becomes eleven options, **12 |
| 456 | first**, because a `<select>` shows its first option until a script |
| 457 | sets one and 12pt is what an unmarked run is. This is the same |
| 458 | mistake the existing comment records having already made once with |
| 459 | Small. |
| 460 | - `views/show.html` — eleven `docs-size-*` classes at real point sizes. |
| 461 | - `internal/export/pdf.go` — `sizeScale` becomes a point lookup rather |
| 462 | than a multiplier ladder. |
| 463 | - `internal/export/markdown.go` — unchanged; Markdown has no sizes. |
| 464 | - **Migration.** The four old marks must keep rendering. `size_sm`, |
| 465 | `size_lg`, `size_xl` stay in `MARKS` permanently, mapped to their |
| 466 | nearest point class. Documents in the wild carry them, and dropping a |
| 467 | mark from `MARKS` is silent data loss by §1.3's own definition. They |
| 468 | simply stop being *offered*. |
| 469 | - Wire fixtures regenerate; wasm rebuilds; `wire_compat` covers it. |
| 470 | |
| 471 | ## 7. The Tab key |
| 472 | |
| 473 | ### 7.1 What Tab does, by context |
| 474 | |
| 475 | | Context | Tab | Shift+Tab | |
| 476 | |---|---|---| |
| 477 | | In a list item, at its start | Demote (nest one level) | Promote | |
| 478 | | In a list item, elsewhere | Demote | Promote | |
| 479 | | Empty paragraph, caret at start | Indent the paragraph | Outdent | |
| 480 | | Paragraph, caret at start | Indent the paragraph | Outdent | |
| 481 | | Paragraph, caret mid-text | Insert a tab | Outdent | |
| 482 | | In a table (when tables land) | Next cell | Previous cell | |
| 483 | |
| 484 | The list cases use `sinkListItem`/`liftListItem` from |
| 485 | `prosemirror-schema-list`, which the bundle already imports half of. |
| 486 | |
| 487 | ### 7.2 Two things Tab must not break |
| 488 | |
| 489 | - **Tab must not trap keyboard focus.** WCAG 2.1.2. Inside the editable |
| 490 | surface Tab indents; that is the standard editor exception and it is |
| 491 | only acceptable because Escape-then-Tab leaves, and because the |
| 492 | toolbar and menu bar are reachable. The existing comment at |
| 493 | `editor-entry.js`:669 already worries about tabbing in from the |
| 494 | sign-out button; this must not regress it. |
| 495 | - **A literal tab character must survive the whole pipeline.** It goes |
| 496 | into the CRDT as text, so `fragment.rs` needs no change — but HTML |
| 497 | collapses it, so `[docs-text]` needs `tab-size` and the run must not |
| 498 | be `white-space: normal`. The PDF exporter measures text and must |
| 499 | advance to the next stop rather than drawing a glyph. Untested, this |
| 500 | is a tab that looks right on screen and vanishes in export. |
| 501 | |
| 502 | ### 7.3 Paragraph indent, and why tab stops are not built tonight |
| 503 | |
| 504 | Indent is a **block attribute**, `indent: 0..8`, clamped to an integer |
| 505 | exactly as `heading.level` already is — so it spends none of §1.3's |
| 506 | property. It renders as a `docs-indent-N` class from a closed table, |
| 507 | like `ALIGNMENTS`. |
| 508 | |
| 509 | **Ruler tab stops are specified but not built.** They need a ruler |
| 510 | widget, per-paragraph stop positions as a *list of numbers* on a block |
| 511 | attribute, and an exporter that honours them. The list-of-numbers part |
| 512 | is the tell: it is the first document-supplied *structure* rather than a |
| 513 | clamped scalar, and it deserves its own plan rather than an hour at 3am. |
| 514 | The View menu carries "Show ruler" disabled to mark the hole. |
| 515 | |
| 516 | ## 8. Autoformat — detecting bullets |
| 517 | |
| 518 | `prosemirror-inputrules` is not currently imported. Rules to add: |
| 519 | |
| 520 | | Typed | Becomes | |
| 521 | |---|---| |
| 522 | | `- ` `* ` `+ ` | Bullet list | |
| 523 | | `1. ` (any number) | Ordered list, starting at that number | |
| 524 | | `> ` | Blockquote | |
| 525 | | `# ` … `###### ` | Heading 1–6 (clamped to the 1–3 the block select offers) | |
| 526 | | `--- ` | Horizontal rule | |
| 527 | | ` ``` ` | Code block (*only if* a code block node exists; it does not — omit) | |
| 528 | |
| 529 | **Undo is the whole feature.** Autoformat that cannot be undone is |
| 530 | infuriating rather than helpful. `undoInputRule` bound to Backspace |
| 531 | immediately after a rule fires reverts the transformation and leaves the |
| 532 | typed characters — this is `prosemirror-inputrules`' own |
| 533 | `undoInputRule`, and it must be bound, or every accidental `- ` becomes |
| 534 | a list the person has to fight. |
| 535 | |
| 536 | Interaction with `y-prosemirror`'s undo: the input rule and its undo are |
| 537 | a single ProseMirror transaction each, so `yUndoPlugin` treats them |
| 538 | atomically. This needs a test, not an assumption — the existing bundle's |
| 539 | undo is `y-prosemirror`'s, not `prosemirror-history`'s, and |
| 540 | `undoInputRule` is written against the latter's assumptions. |
| 541 | |
| 542 | Smart quotes and em-dash substitution are **not** included: they are the |
| 543 | autoformat people most often want off, and there is no preferences |
| 544 | surface to turn them off in. |
| 545 | |
| 546 | ## 9. Pages — infinite by default, paged on request |
| 547 | |
| 548 | ### 9.1 What paged means when the CRDT stores a flat block sequence |
| 549 | |
| 550 | **Nothing about pagination is persisted, and nothing about it is |
| 551 | shared.** Page breaks are a *view* computed in the browser from measured |
| 552 | block heights against the page box. Two people editing the same document |
| 553 | at different zoom levels see different page breaks and that is correct — |
| 554 | the document is the block sequence; pages are a rendering of it. |
| 555 | |
| 556 | This is the decision that keeps pagination out of the CRDT, and it |
| 557 | follows from concurrency: a persisted page break would be an edit, would |
| 558 | merge, and two people typing would fight over where page 3 starts. |
| 559 | |
| 560 | The consequence to state plainly: **screen pagination and PDF |
| 561 | pagination are computed by different code** (browser layout vs |
| 562 | `internal/export/pdf.go`) and will not always agree. `show.html`'s |
| 563 | existing comment already claims they agree because both measure with the |
| 564 | same font programs; that claim is true for line breaking and is *not* |
| 565 | true for page breaking unless the page box, margins and widow/orphan |
| 566 | rules match exactly. Either they are made to match, or the claim comes |
| 567 | out of the comment. It must not be left asserted and untrue — §10.3's |
| 568 | own failure mode, and the one this repo has already been bitten by. |
| 569 | |
| 570 | ### 9.2 The toggle |
| 571 | |
| 572 | `View → Paged` / `View → Infinite`, a pair of `<button>`s carrying |
| 573 | `aria-pressed` — not `menuitemradio`, for §3.3's reason: these menus are |
| 574 | disclosures and their items are ordinary buttons. It is a **per-person |
| 575 | preference, not a document property** — how you like to read is not |
| 576 | something you impose on a collaborator. Stored in a **cookie**, |
| 577 | defaulting to infinite. |
| 578 | |
| 579 | A cookie rather than the `localStorage` this first specified, and the |
| 580 | reason is in the same sentence that specified it: "applied before first |
| 581 | paint". `localStorage` is invisible to the server, so the mode could |
| 582 | only be applied once the deferred bundle ran — which is a document |
| 583 | visibly reflowing from infinite into pages a moment after it appears. |
| 584 | The server reads the cookie and renders `data-view` on the canvas, so |
| 585 | the first paint is already right. |
| 586 | |
| 587 | Infinite is one `[docs-page]` box that grows. Paged is the SAME single |
| 588 | box, with a desk-coloured gap drawn between pages as a widget |
| 589 | decoration inside the text flow. |
| 590 | |
| 591 | Not one `[docs-page]` per page, which is what this said first and what |
| 592 | `show.html`'s own comment anticipated ("one box, its own padding, ready |
| 593 | to hold more than one sibling"). ProseMirror owns everything inside the |
| 594 | editable element (`{mount: el}`), so its children cannot be wrapped in |
| 595 | sibling page boxes without taking the document away from it. Repeating |
| 596 | the box is a layout-engine shape; a decoration is a |
| 597 | pagination-as-a-view shape, which is what §9.1 already committed to. |
| 598 | `show.html`'s comment is corrected in the same commit. |
| 599 | |
| 600 | ### 9.3 Explicit page breaks |
| 601 | |
| 602 | `Insert → Page break` is a real node (`page_break`), a `MARKS`-style |
| 603 | literal in `fragment.rs`, rendering as an empty block. In infinite mode |
| 604 | it draws as a labelled rule; in paged mode it forces a break; in PDF it |
| 605 | forces a break. It is the only part of pagination that *is* in the |
| 606 | document, and it is legitimate because the author asked for it. |
| 607 | |
| 608 | ## 10. Links |
| 609 | |
| 610 | Implemented exactly as `docs/superpowers/plans/2026-09-02-images-links-tables.md` |
| 611 | specifies — that plan is written, Fable-reviewed and revised, and this |
| 612 | spec adopts it rather than restating it. The four points most easily got |
| 613 | wrong, recorded because getting any of them wrong is a security bug: |
| 614 | |
| 615 | 1. **The scheme allowlist runs in the shared path**, not in |
| 616 | `node_html`. A rejected `href` means the run is emitted **with no |
| 617 | `link` mark at all**, in `fragment_json` and `text_json` as much as |
| 618 | in `fragment_html`. Otherwise `markdown.go` emits |
| 619 | `[text](javascript:…)` and the PDF's `/Link` annotation carries the |
| 620 | same. |
| 621 | 2. **Positive grammar, not a denylist**: `^[A-Za-z][A-Za-z0-9+.-]*:` |
| 622 | with the scheme, case-folded, in {`http`, `https`, `mailto`}. |
| 623 | Everything else renders as text — including the no-colon case, which |
| 624 | a control-character denylist misses entirely. |
| 625 | 3. **`internal/export/document.go` has to grow.** `Mark` is |
| 626 | `{Type string}` and `Attrs` is closed to Level/Align; both widen, and |
| 627 | `text_json` must emit mark attrs, or no exporter can see the href. |
| 628 | 4. **The wire fixture is generated, not hand-built.** A mark's wire |
| 629 | value is an attrs *object* under the mark name — a shape no existing |
| 630 | fixture has. Generate it through `y-prosemirror`'s own |
| 631 | `prosemirrorToYXmlFragment` so Rust is tested against the real |
| 632 | encoding rather than a transcription of it. |
| 633 | |
| 634 | `rel="noreferrer"`, not `noopener` — the plan sets no `target`, and |
| 635 | `noopener` does nothing without one. |
| 636 | |
| 637 | UI: `Insert → Link` (Ctrl-K conflicts with the existing jump-to |
| 638 | palette — **use Ctrl-Shift-K for link, and say so in the shortcuts |
| 639 | sheet**, rather than stealing a binding people already use). |
| 640 | `insert-link` and `remove-link` are the command names, agreed with the |
| 641 | session that owns the rest of Plan 7 so `insert-image` and |
| 642 | `insert-table` drop into the same table later. |
| 643 | |
| 644 | ## 11. The remaining basics |
| 645 | |
| 646 | - **Word count** — characters, words, paragraphs; whole document and |
| 647 | selection. A `Tools`/`View` item opening a small panel. Counting is |
| 648 | over ProseMirror's document, in the browser. Word segmentation uses |
| 649 | `Intl.Segmenter` where available, because splitting on whitespace is |
| 650 | wrong for most of the twelve locales. |
| 651 | - **Find and replace** — in-document, over ProseMirror, with a |
| 652 | decoration for matches. Distinct from the existing cross-document |
| 653 | search (`search.go`), and must not be confused with it in copy: that |
| 654 | one finds *documents*, this one finds *text in this document*. |
| 655 | - **Print** — `window.print()` plus a print stylesheet that hides all |
| 656 | chrome, forces paged geometry regardless of the view toggle, and sets |
| 657 | real page margins. Not a PDF re-implementation. |
| 658 | - **Page numbers** — in paged mode and in print/PDF only. Not stored. |
| 659 | - **Keyboard shortcuts sheet** — a `Tools` item listing every binding, |
| 660 | generated from the command table rather than hand-maintained, so it |
| 661 | cannot drift. |
| 662 | - **Clear formatting** — removes every mark in the exclusive groups and |
| 663 | resets the block to paragraph. Cheap, and the thing people reach for |
| 664 | after a bad paste. |
| 665 | |
| 666 | ## 12. Document tabs — specified, built later |
| 667 | |
| 668 | Google Docs' tabs are named sections in a left rail, each its own |
| 669 | sub-document, reorderable and nestable one level. |
| 670 | |
| 671 | The shape, so the later plan does not have to reopen it: a document |
| 672 | gains an ordered list of tabs; each tab owns **its own Yjs |
| 673 | `XmlFragment`** under one Yjs document, keyed by tab id. One document, |
| 674 | one update log, one snapshot — so sharing, sync and ownership are |
| 675 | unchanged. `fragment.rs` gains a fragment-selecting entry point; |
| 676 | `Store.HTML` renders the tab asked for; the read view renders the first. |
| 677 | |
| 678 | It is *not* built tonight because it changes the document model, every |
| 679 | export path, the read view and the URL space at once, and because |
| 680 | nothing else in this spec depends on it. It is written down because |
| 681 | several decisions above — the View menu's shape, `Store.HTML`'s |
| 682 | signature — are cheaper to make now in a way that does not preclude it. |
| 683 | |
| 684 | ## 13. Decomposition |
| 685 | |
| 686 | Ordered by dependency. Branches within a group are parallel-safe; |
| 687 | groups are serial. The file that forces most of the serialisation is |
| 688 | `views/show.html`, which every UI change touches. |
| 689 | |
| 690 | | # | Branch | Touches | Depends on | |
| 691 | |---|---|---|---| |
| 692 | | 1 | Selected states + searchable selects | `show.html` (CSS only) | — | |
| 693 | | 2 | Font size enumeration | `fragment.rs`, `editor-entry.js`, `toolbar.mjs`, `export.go`, `pdf.go`, fixtures, wasm | — | |
| 694 | | 3 | Tab key + indent | `editor-entry.js`, `fragment.rs` (indent table), `pdf.go` | 2 (both touch `fragment.rs`) | |
| 695 | | 4 | Autoformat input rules | `editor-entry.js` | 3 (same keymap) | |
| 696 | | 5 | Menu bar | `show.html` (heavily), `render.go`, `export.go`, new `fixtures/menu.mjs` | 1 | |
| 697 | | 6 | Pages: infinite/paged + page break | `show.html`, `fragment.rs`, `pdf.go`, new `fixtures/pages.mjs` | 5 (View menu), 2 | |
| 698 | | 7 | Links | `fragment.rs`, `document.go`, `markdown.go`, `pdf.go`, `editor-entry.js`, fixtures | 2, 5 (Insert menu) | |
| 699 | | 8 | Word count, find/replace, print, shortcuts | `show.html`, new fixtures | 5 | |
| 700 | |
| 701 | Branches 1 and 2 start immediately and in parallel. Everything else |
| 702 | queues behind them. |
| 703 | |
| 704 | **`fragment.rs` is the contended file** — branches 2, 3, 6 and 7 all |
| 705 | change `MARKS` or a node table, and each regenerates wire fixtures and |
| 706 | the wasm. They must land one at a time, rebasing, and each must |
| 707 | rebuild-and-commit the wasm in one step (§1.5). |
| 708 | |
| 709 | ## 14. The five things most likely to go wrong |
| 710 | |
| 711 | 1. **The wasm/bundle gate deletes work.** On failure it runs |
| 712 | `git clean -fd` over `internal/docs/static/`. Guard: rebuild and |
| 713 | commit as one step, every time, and run the gate locally before |
| 714 | pushing. |
| 715 | 2. **A mark or node reaches `fragment.rs` unhandled** and renders as |
| 716 | nothing — the module's own "silence here is data loss". Guard: every |
| 717 | new schema element gets a `MARKS`/node-table row *and* a generated |
| 718 | wire fixture in the same commit as the schema change. |
| 719 | 3. **320px regresses under the menu bar** and nobody sees it, because |
| 720 | Chromium will not start in the sandbox. Guard: the reflow check is |
| 721 | extended, not relaxed, and the collapsed arrangement is checked too; |
| 722 | if it cannot be run, that is stated rather than assumed passing. |
| 723 | 4. **Autoformat undo fights `y-prosemirror`'s undo**, because |
| 724 | `undoInputRule` is written against `prosemirror-history`. Guard: a |
| 725 | test that types `- `, presses Backspace, and asserts the list is |
| 726 | gone and the characters remain — before the rules ship. |
| 727 | 5. **Screen and PDF pagination disagree** while a comment asserts they |
| 728 | agree. Guard: either make the page boxes match, or delete the claim. |
| 729 | Nothing stays asserted and untrue. |
| 730 | |