oficina / docs Public

Spec: what a word processor is expected to already do

Paul's overnight brief — tabs, bullet detection, font size, a menu
system instead of two export buttons pinned to the formatting bar, the
selected-state defect he screenshotted, design-system selects, and
paged/infinite pages.

Two findings that changed the shape of the work before any of it
started:

The searchable select needs no rastrillo upgrade. ui/select.js is
byte-identical between the pinned v0.22.0 and v0.25.0, and render.go
already serves it — the three toolbar selects only ever needed the
data-rst-select opt-in. The upgrade was tried anyway (module path moves
to amadan.net/rastrillo/rastrillo, ten import lines, all six packages
green) and then deliberately left out: it carries a 138-line tokens.css
change nothing here can verify without a browser, and the sandbox will
not start one.

Font size cannot be a valued mark. fragment.rs's output is written
unescaped, which is safe only because no byte from a document reaches
attribute position; a mark carrying "14pt" breaks exactly that, and
POST /d/{sqid}/updates takes arbitrary client bytes that never met the
schema. So the point sizes are a closed enumeration of attribute-free
marks — eleven more rows of the discipline the file already has, and no
new safety decision.

The selected state is fixed here rather than specced: a one-sided rule
inside a rounded box follows the curve instead of stopping at the
corner, which is what made it read as a tab stem. A full inset ring on
the same radius says the same thing, still costs no layout, and leaves
the 320px budget alone.

Also records the base this was nearly built on wrong. The first reading
had 39 commits of landed work as unlanded, from citing a local main ref
that had never been fetched — the second entry under AGENTS.md's "tools
that answer wrongly instead of failing", hit verbatim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrHJFx6wn4VvTzc3drkTp4
Paul Campbell pushed by paul@keymail.dev cd5e8853d9450e00bd6e0a36d26dbcde50fa8734 parent 1b6d392
2 files changed, +634 −7
  • docs/superpowers/specs/2026-09-02-word-processor-basics-design.md +610 −0
  • internal/docs/views/show.html +24 −7
diff --git a/docs/superpowers/specs/2026-09-02-word-processor-basics-design.md b/docs/superpowers/specs/2026-09-02-word-processor-basics-design.md
new file mode 100644
index 0000000..0be0367
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-02-word-processor-basics-design.md
@@ -0,0 +1,610 @@
+# 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
+
+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.
+
+**File** — New document · Open (goes to the list) · Rename · Make a
+copy · *divider* · Download ▸ (PDF, 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 (§8.4) ·
+Horizontal rule · *divider* · Special characters (*disabled*)
+
+**Format** — Text ▸ (Bold, Italic, Code) · Paragraph styles ▸
+(Normal, Heading 1–3) · Font ▸ · Size ▸ · Align ▸ · *divider* ·
+Bullets and numbering ▸ · Indent / Outdent · *divider* · Clear
+formatting
+
+**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-checked`. Nothing listens for clicks to infer state.
+
+### 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.
+
+- `role="menubar"` with `role="menuitem"` / `menuitemcheckbox` /
+ `menuitemradio` children; submenus `role="menu"` with
+ `aria-haspopup="true"` and `aria-expanded`.
+- **Roving tabindex**, reusing `toolbar-roving.mjs` rather than a second
+ implementation: the whole bar is one tab stop; Left/Right move
+ between menus; Down or Enter opens; Up/Down move within; Escape
+ closes and returns focus to the menu title; Tab from an open menu
+ closes it and leaves the bar.
+- 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 does not scroll and does not overflow.** Six menu
+ titles do not fit 320px. Below a breakpoint the bar collapses to a
+ single `Menu` button opening one `role="menu"` whose items are the
+ six menus as submenus. This is one component in two arrangements, not
+ two components: same command table, same names, same tests. The
+ reflow check must be green with the menu bar present, and must be
+ extended to cover the collapsed arrangement — 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.
+
+Three 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. 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 selects are `disabled` until the editor mounts.** An enhanced
+ combobox over a disabled select is a control that looks usable and
+ is not. The enhancement must mirror the disabled state, and
+ `toolbar.mjs` must enable both halves when it enables the bar.
+3. **The framework's own judgement is that search below ten options is
+ furniture, not help** (`field-select.html`: it emits the opt-in only
+ past ten). Font has three options and block has four. After §6 the
+ size select has eleven and earns it outright. Applying the
+ enhancement to all three is Paul's explicit instruction and it is
+ what makes the toolbar read as one design system rather than three
+ native controls in a dark bar — but the *filter input* is suppressed
+ below ten, so block and font get the design-system control without a
+ search box that would filter four items.
+
+## 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 `menuitemradio` pair. 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
+`localStorage`, defaulting to infinite, applied before first paint to
+avoid a visible reflow.
+
+Infinite is one `[docs-page]` box that grows. Paged repeats
+`[docs-page]` once per computed page with the desk showing between them
+— the shape `show.html`'s own comment says it was built to allow
+("one box, its own padding, ready to hold more than one sibling").
+
+### 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.
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
index 5cb89c0..631b6d6 100644
--- a/internal/docs/views/show.html
+++ b/internal/docs/views/show.html
@@ -662,17 +662,34 @@
alone — which is exactly the thing §10.3 forbids, asserted by a
comment to be handled while it was not.
- The inset bar is what actually carries it: a 2px rule under the
- glyph, either present or absent, independent of hue, and equally
- legible on the icon buttons and on B/I/<>. box-shadow rather than a
- border so it costs no layout and cannot shrink the 28px target. The
- weight stays because it still helps the three lettered buttons; it
- is no longer what this rule depends on. */
+ A complete 1px ring is what carries it: present or absent,
+ independent of hue, and equally legible on the icon buttons and on
+ B/I/<>. The weight stays because it still helps the three lettered
+ buttons; it is no longer what this rule depends on.
+
+ It was a 2px rule under the glyph until Paul looked at it
+ (2026-09-02) and asked for "simple borders, not the weird rounded
+ one-side". He was reading a real defect, not a preference: a
+ one-sided rule inside a box with `border-radius` does not stop at
+ the corner, it follows the curve, so the underline turned up at
+ both ends and the pressed button read as a tab or a tooltip stem
+ rather than a selected control. Either the rule or the radius had
+ to go, and the radius is the house style every other control on
+ this screen is drawn with.
+
+ Still box-shadow rather than `border`, and for the reason the
+ previous comment gave: it costs no layout, so it cannot shrink the
+ 28px target and cannot widen the bar. That last part is not
+ housekeeping — the toolbar has to fit 320px (AGENTS.md's reflow
+ rule, no exemptions), and a real 1px border on nine buttons would
+ have spent 18px of that budget to say the same thing. `inset 0 0 0
+ 1px` draws the ring on the inside of the same box, following the
+ same radius the rest of the control already has. */
[docs-tool][aria-pressed="true"] {
background: var(--rst-accent-soft);
color: var(--rst-accent);
font-weight: 700;
- box-shadow: inset 0 -2px 0 0 currentColor;
+ box-shadow: inset 0 0 0 1px currentColor;
}
[docs-tool]:disabled {
color: var(--rst-text-faint);