oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Word-processor basics — menus, tabs, autoformat, sizes and pages
2
3Brief, from Paul, 2026-09-02, going into an unattended overnight run:
4*"implement a full-stack of the expected basic features that our word
5processor should have. Starting with things like: tabs, detecting
6bullets, font-size, a menu system with e.g. Export rather than random
7buttons... fix these selected states to just have simple borders not the
8weird rounded one-side... selects should be Rastrillo design system
9searchable selects... multiple pages, and the ability to switch between
10paged and infinite mode... anything else that a Fable brainstorm
11recommends for a maximal viable Docs app."*
12
13Four things were settled by Paul before he signed off, and are not
14reopened 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
31This spec's first draft was written against a local `main` ref that had
32not been fetched, and concluded that 39 commits of finished work — the
33toolbar, fonts, PDF and Markdown export, alignment, the sans default,
34live sync, search, snapshots, mobile margins — were unlanded and needed
35taking through the gate. All of it was already on `origin/main`. The
36peer session doing discussions triage caught it within minutes.
37
38This 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
41on a feature branch gives you true statements about the wrong tree."*
42The check that would have caught it is one command — `git fetch origin`
43before `git rev-list --count origin/main..<branch>` — and the reason it
44was skipped is that `git log` on the main checkout *answered*, fluently
45and wrongly, instead of failing.
46
47It is recorded here rather than in a commit message because the same
48trap is sitting in front of whoever implements this spec: several
49branches (`worktree-import-documents`, the `*-squashed` family) are
50still ahead of `origin/main` tonight, and the same wrong reading is
51available 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
56the browser over a Yjs document, a Rust CRDT core (`core/`) compiled to
57wasm and run server-side under wazero, and a server-rendered read view
58that works with scripts disabled.
59
60Already 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
76Genuinely absent, and therefore this spec's actual scope: **no `Tab`
77binding anywhere** (`editor-entry.js`'s keymap is `Mod-z/y/b/i` plus
78`baseKeymap`), **no `inputRules` import at all**, no menu, no
79pagination, 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
84as `template.HTML` by `Store.HTML` and written **unescaped** by
85`views/show.html`. That is safe today because of one property the module
86states 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
96Two consequences that decide two of this spec's features:
97
981. **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.
1022. **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
108Anything the toolbar or a menu can produce that `fragment.rs` cannot
109round-trip is silent data loss, which is the module comment's own
110phrase 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
115Three sessions are working in parallel, and the boundaries were agreed
116directly 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
146Paul's brief says searchable selects "might need to get the latest
147Rastrillo". Checked, and the condition is false: **`ui/select.js` is
148byte-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
150serves it — `vendoredStaticFS` maps `static/select.js` to
151`ui.SelectJS()`, and the shell's `<head>` links it unconditionally.
152
153So the searchable select needs **no upgrade at all**, only the
154`data-rst-select` opt-in on the three toolbar selects (§5).
155
156The upgrade was nevertheless attempted, to see what it costs: the module
157path changes to `amadan.net/rastrillo/rastrillo`, ten Go files change
158their import line, and all six packages pass. It is *safe*, but it
159carries a 138-line `tokens.css` change that nothing in this repo can
160visually verify tonight (§1.5 — no browser in the sandbox), and it
161would 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
164own change, on its own branch, merged only once someone has looked at
165the result in a browser. Bundling an unverifiable visual change into a
166feature push nobody is awake to check is how a night's work becomes a
167morning's bisect.
168
169## 3. The menu bar
170
171### 3.1 What it replaces
172
173Two ghost buttons, `PDF` and `MD`, pinned to the trailing end of the
174formatting bar (`show.html`'s `[docs-doc-actions]`). They are Paul's
175"random buttons": export is a File-menu verb everywhere a person has
176met one, and putting it in the formatting bar puts a document-level
177action 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
182not the record.** Built and reachable: the whole bar; File's New
183document, Open, both downloads, Print and Sign out; Edit's Undo, Redo
184and Select all; View's Paged/Infinite pair; Format's marks, paragraph
185styles, alignments, lists, quote, indent/outdent and Clear formatting;
186Tools' Word count and Keyboard shortcuts. Rendered and greyed, each
187naming the section it waits on: Insert's Link, Image, Table, Page break
188and 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
191setup, Cut/Copy/Paste, Full screen, Special characters, Spelling. The
192list below still names them because it is the design; they are called
193out here so nobody reads an unmarked line as a promise that was kept.
194Cut/Copy/Paste in particular should not be built — a `<button>` cannot
195drive the clipboard reliably (`execCommand` is deprecated,
196`navigator.clipboard.read` is permission-gated and absent in Firefox),
197and a menu item that sometimes fails is worse than one that is not
198there.
199
200Real tonight unless marked. Items that are present-but-disabled are
201listed because a menu that hides what the app will obviously grow is a
202menu that gets redesigned; one that greys it is a menu that gets filled
203in. Nothing is listed that has no plan behind it.
204
205Submenus are flattened rather than nested, for the reason §3.4 gives.
206
207**File** — New document · Open (goes to the list) · Rename · Make a
208copy · *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
212present and route to the browser's own clipboard behaviour, with their
213real shortcuts shown; they are not re-implemented) · *divider* · Find
214and 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
221key) · Table (*disabled*, Plan 7) · *divider* · Page break (§9.3) ·
222Horizontal rule · *divider* · Special characters (*disabled*)
223
224**Format** — Bold · Italic · Code · *divider* · Normal text ·
225Heading 1 · Heading 2 · Heading 3 · *divider* · Align left · Align
226centre · Align right · *divider* · Bulleted list · Numbered list ·
227Indent · Outdent · *divider* · Clear formatting
228
229Font and Size are deliberately **not** in the Format menu. They are the
230two controls whose current value has to be *visible* — a person needs to
231see that they are in 12pt Sans without opening anything — and a menu
232item cannot show that the way the toolbar's select does. Duplicating
233them as eleven point sizes and three faces would also make Format the
234longest menu in the app to say what the bar beside it already says.
235
236**Tools** — Word count · *divider* · Keyboard shortcuts · *divider* ·
237Spelling (*disabled* — the browser's own spellcheck is on; this item
238would be a second, worse one)
239
240Every string is a catalog key in `render.go`'s `Catalog`. There are
241roughly fifty. A hardcoded English string is a bug (AGENTS.md).
242
243### 3.3 The two-ways-to-do-everything problem
244
245A menu bar over an existing toolbar duplicates most of the toolbar. That
246is correct and deliberate — it is what every word processor does — but
247it 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
272The app's rule is WCAG 2.2 AA with no exemptions, so this is a
273requirement list, not a wish list.
274
275**The menu is `<details>`/`<summary>`, and stays a disclosure.** This
276reverses what this section said in its first draft, which was
277`role="menubar"` with `menuitem` children and a JS-driven roving
278tabindex. The Sheets session — building the same chrome tonight — asked
279which of the two we had picked, and checking the answer showed the
280menubar 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
304JavaScript then *adds*, and never enables: Left/Right between summaries,
305Up/Down within an open menu, Escape closes and returns focus to its
306summary, Tab leaves the bar, and a **roving tabindex** across the
307summaries — reusing `toolbar-roving.mjs` rather than a second
308implementation — 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
334The design system is rastrillo's and this screen already has a settled
335voice: a dark chrome bar over a light page, `rst-*` attribute grammar,
336`--rst-*` tokens. The menu bar is **not** an opportunity for a new
337visual identity; it is an opportunity to make the chrome read as one
338object 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
3550 0` under the glyph. Inside a box with `border-radius`, a one-sided
356rule follows the curve rather than stopping at the corner, so the
357underline turned up at both ends — Paul's "weird rounded one-side", and
358a real defect rather than a preference: the pressed button read as a tab
359stem, not a selected control.
360
361Fixed to `box-shadow: inset 0 0 0 1px currentColor` — a complete ring on
362the same radius. Still `box-shadow` rather than `border`, for the reason
363the original comment gave and which matters more now: it costs no
364layout, so it cannot shrink the 28px target or widen the bar, and a real
365border on nine buttons would have spent 18px of the 320px budget to say
366the same thing. The non-colour signal §10.3 requires is preserved: the
367ring is present or absent independent of hue.
368
369## 5. Searchable selects
370
371The three toolbar selects (`[docs-tool-select]`: block, font, size) are
372hand-styled native `<select>`s. They become design-system comboboxes by
373opting 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
377asserted in its first draft are false, and both were checked only
378because 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
389Four things this must get right, none of which the opt-in gives for
390free:
391
3921. **`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.
3982. **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.
4053. **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.
4104. **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
418with that.** `field-select.html` says search below ten options is
419"furniture, not help", and font has three options and block has four.
420Opting in all three is Paul's explicit instruction, and it is also the
421better call for a reason the framework's threshold cannot see: these
422three sit *side by side in one group*, and one combobox flanked by two
423native selects looks worse than either extreme. All three are also
424growth surfaces — §6 takes size to eleven immediately, spec §11.2
425commits to named styles in the block list, and the face list is
426designed to grow. The tension is recorded rather than resolved silently:
427if the filter over three fonts reads as silly in the morning, the fix is
428one attribute, not a redesign.
429
430## 6. Font size
431
432Today: four named steps (`normal`, `sm`, `lg`, `xl`), stored as three
433attribute-free marks. Paul asked for font size, which in every word
434processor 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.
439Each is an attribute-free mark emitting a fixed class from a literal row
440in `MARKS`, exactly as the existing three do.
441
442This is the whole of §1.3's reasoning applied: eleven more rows of the
443same discipline, and **no new safety decision**. A mark carrying `"14pt"`
444would be a valued mark, would put a document byte in attribute position,
445and would need Plan 7's escaping work first.
446
447What 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
484The 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
504Indent is a **block attribute**, `indent: 0..8`, clamped to an integer
505exactly as `heading.level` already is — so it spends none of §1.3's
506property. It renders as a `docs-indent-N` class from a closed table,
507like `ALIGNMENTS`.
508
509**Ruler tab stops are specified but not built.** They need a ruler
510widget, per-paragraph stop positions as a *list of numbers* on a block
511attribute, and an exporter that honours them. The list-of-numbers part
512is the tell: it is the first document-supplied *structure* rather than a
513clamped scalar, and it deserves its own plan rather than an hour at 3am.
514The 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
530infuriating rather than helpful. `undoInputRule` bound to Backspace
531immediately after a rule fires reverts the transformation and leaves the
532typed characters — this is `prosemirror-inputrules`' own
533`undoInputRule`, and it must be bound, or every accidental `- ` becomes
534a list the person has to fight.
535
536Interaction with `y-prosemirror`'s undo: the input rule and its undo are
537a single ProseMirror transaction each, so `yUndoPlugin` treats them
538atomically. This needs a test, not an assumption — the existing bundle's
539undo is `y-prosemirror`'s, not `prosemirror-history`'s, and
540`undoInputRule` is written against the latter's assumptions.
541
542Smart quotes and em-dash substitution are **not** included: they are the
543autoformat people most often want off, and there is no preferences
544surface 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
551shared.** Page breaks are a *view* computed in the browser from measured
552block heights against the page box. Two people editing the same document
553at different zoom levels see different page breaks and that is correct —
554the document is the block sequence; pages are a rendering of it.
555
556This is the decision that keeps pagination out of the CRDT, and it
557follows from concurrency: a persisted page break would be an edit, would
558merge, and two people typing would fight over where page 3 starts.
559
560The consequence to state plainly: **screen pagination and PDF
561pagination are computed by different code** (browser layout vs
562`internal/export/pdf.go`) and will not always agree. `show.html`'s
563existing comment already claims they agree because both measure with the
564same font programs; that claim is true for line breaking and is *not*
565true for page breaking unless the page box, margins and widow/orphan
566rules match exactly. Either they are made to match, or the claim comes
567out of the comment. It must not be left asserted and untrue — §10.3's
568own 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
574disclosures and their items are ordinary buttons. It is a **per-person
575preference, not a document property** — how you like to read is not
576something you impose on a collaborator. Stored in a **cookie**,
577defaulting to infinite.
578
579A cookie rather than the `localStorage` this first specified, and the
580reason is in the same sentence that specified it: "applied before first
581paint". `localStorage` is invisible to the server, so the mode could
582only be applied once the deferred bundle ran — which is a document
583visibly reflowing from infinite into pages a moment after it appears.
584The server reads the cookie and renders `data-view` on the canvas, so
585the first paint is already right.
586
587Infinite is one `[docs-page]` box that grows. Paged is the SAME single
588box, with a desk-coloured gap drawn between pages as a widget
589decoration inside the text flow.
590
591Not 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
593to hold more than one sibling"). ProseMirror owns everything inside the
594editable element (`{mount: el}`), so its children cannot be wrapped in
595sibling page boxes without taking the document away from it. Repeating
596the box is a layout-engine shape; a decoration is a
597pagination-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
603literal in `fragment.rs`, rendering as an empty block. In infinite mode
604it draws as a labelled rule; in paged mode it forces a break; in PDF it
605forces a break. It is the only part of pagination that *is* in the
606document, and it is legitimate because the author asked for it.
607
608## 10. Links
609
610Implemented exactly as `docs/superpowers/plans/2026-09-02-images-links-tables.md`
611specifies — that plan is written, Fable-reviewed and revised, and this
612spec adopts it rather than restating it. The four points most easily got
613wrong, recorded because getting any of them wrong is a security bug:
614
6151. **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.
6212. **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.
6253. **`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.
6284. **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
637UI: `Insert → Link` (Ctrl-K conflicts with the existing jump-to
638palette — **use Ctrl-Shift-K for link, and say so in the shortcuts
639sheet**, rather than stealing a binding people already use).
640`insert-link` and `remove-link` are the command names, agreed with the
641session 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
668Google Docs' tabs are named sections in a left rail, each its own
669sub-document, reorderable and nestable one level.
670
671The shape, so the later plan does not have to reopen it: a document
672gains an ordered list of tabs; each tab owns **its own Yjs
673`XmlFragment`** under one Yjs document, keyed by tab id. One document,
674one update log, one snapshot — so sharing, sync and ownership are
675unchanged. `fragment.rs` gains a fragment-selecting entry point;
676`Store.HTML` renders the tab asked for; the read view renders the first.
677
678It is *not* built tonight because it changes the document model, every
679export path, the read view and the URL space at once, and because
680nothing else in this spec depends on it. It is written down because
681several decisions above — the View menu's shape, `Store.HTML`'s
682signature — are cheaper to make now in a way that does not preclude it.
683
684## 13. Decomposition
685
686Ordered by dependency. Branches within a group are parallel-safe;
687groups 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
701Branches 1 and 2 start immediately and in parallel. Everything else
702queues behind them.
703
704**`fragment.rs` is the contended file** — branches 2, 3, 6 and 7 all
705change `MARKS` or a node table, and each regenerates wire fixtures and
706the wasm. They must land one at a time, rebasing, and each must
707rebuild-and-commit the wasm in one step (§1.5).
708
709## 14. The five things most likely to go wrong
710
7111. **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.
7152. **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.
7193. **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.
7234. **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.
7275. **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