| 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. |