oficina / docs Public

Links, the ruler, and a plain-text renderer that was emitting markup

The last two greyed-out menu items, and a bug found on the way.

## Links: the first valued mark

Every other mark in this app is attribute-free by design, and
fragment.rs's MARKS table explains at length why that property is worth
keeping: nothing from the document reaches attribute position, so
Store.HTML's template.HTML return is honest and the read view is
written out unescaped. A link breaks that, on purpose, under an
allowlist.

link_href is that allowlist and it sits exactly where mark_names' own
comment said it had to — the single point the HTML renderer and every
JSON consumer agree on what a run carries, so markdown.go and pdf.go
cannot re-implement it differently. Three barriers, each enough alone:
the value must be a map with a string href; the scheme must be one of
https, http, mailto, matched at position zero on a lowercased copy with
no trimming anywhere (trimming is what turns an allowlist back into a
denylist — " javascript:" fails precisely because nothing strips the
space); and whatever survives still goes through escape(), so it could
not close its own attribute even if it got past the first two.

A refused href drops the ANCHOR and keeps the TEXT, which is the rule
this module follows for everything it does not understand.

There are two allowlists, in Rust and in JS, and neither trusts the
other. The Rust one matters — it runs on bytes that never went through
ProseMirror's schema. The JS one exists so a person typing
"javascript:void(0)" is told now, rather than having a link that works
in the editor and is silently declined everywhere else.

THE TESTS WERE WRONG BEFORE THEY WERE RIGHT, and the spec predicted it.
§10.4 says to generate the wire fixture through y-prosemirror rather
than hand-build it. I hand-built it first: an Any::Map with an "href"
key, which is the same assumption link_href makes — so the code and its
tests would have agreed with each other while being wrong together, and
the read view would have rendered no links at all with everything
green. Running the real converter shows the encoding carries
{href, title: null}: two keys, not one. A test that had insisted on a
single-key map would have refused every real link and passed.
vectors/fragment/linked.bin is now generated, and a test reads it.

Removing either allowlist's scheme check turns tests red — two in Rust,
three in JS. Both mutation-tested.

## A plain-text renderer that was emitting markup

fragment_text returned "<strong>bold words</strong>" for a bold
paragraph. yrs' XmlTextRef::get_string serialises a formatted run's
attributes as XML tags, and node_text called it.

Latent — Store.Text has no caller in a handler yet — and found by a
LINK test, because <link href="…"> is long enough to notice where
<strong> had sat unnoticed since marks existed. No test had ever asked
that renderer what a MARKED run looks like. It walks diff now, like the
other two renderers, and two tests hold it: one over every known mark,
one over an unknown mark straight off the wire, which is the case
get_string was worst at.

## The ruler

It measures; it does not drag, and ruler.mjs argues that at length.
Dragging the indent marker would work today — indent is a real block
attribute with a real command — but a ruler where one marker drags and
clicking anywhere else does nothing invites the gesture that would
place a tab stop, and tab stops still do not exist. Tab, Shift+Tab and
the two Format items remain how the indent moves.

It lives inside the canvas rather than in the chrome, which is what
keeps it aligned: same flow container as [docs-page], same width, same
auto margins, so it sits over the paper at every width and through a
narrow window's horizontal scroll with nothing measuring anything in
script.

Its offset under the chrome is a constant this file chose, and the
comment says so rather than claiming it is "the chrome's height" —
nothing here can know that. The browser drive measures the real boxes
instead, for the ruler and for the find panel.

## aria-pressed never moved

Found while wiring the ruler's toggle. menu.go renders aria-pressed on
the View menu's paged/infinite pair from the cookie, so the first paint
is right — and nothing updated it afterwards. Switching to paged left
"Infinite" still reporting itself as pressed for the life of the page:
the one surface telling a screen reader which mode is current told it
the wrong one the moment anybody changed modes. menu.mjs now has
setPressed, and both the pair and the ruler use it.

Insert → Image stays greyed, and now says why in menu.go: there is no
asset store, no upload route and no sealing key, and a link to a remote
image would put a third party's request log in the middle of somebody's
document. That is Paul's decision, not a branch's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoVTH3jZqj9svgt2UD9t1p
Paul Campbell pushed by paul@keymail.dev 421a01b9a142d26d850ab5c063f310abd0ea629d parent ef4461c
26 files changed, +2418 −73
  • .amadan/ci.d/10-gate +18 −0
  • .gitignore +11 −0
  • core/src/fragment.rs +201 −5
  • core/tests/fragment.rs +335 −0
  • docs/superpowers/specs/2026-09-02-word-processor-basics-design.md +81 −1
  • fixtures/browser-drive.mjs +209 −27
  • fixtures/editor-entry.js +62 −0
  • fixtures/generate-fragment.mjs +44 −1
  • fixtures/links.mjs +236 −0
  • fixtures/links.test.mjs +200 −0
  • fixtures/menu.mjs +25 −2
  • fixtures/ruler.mjs +127 −0
  • fixtures/ruler.test.mjs +58 −0
  • fixtures/vectors/fragment/linked.bin binary
  • internal/docs/handlers.go +34 −6
  • internal/docs/menu.go +102 −9
  • internal/docs/render.go +15 −0
  • internal/docs/ruler_test.go +125 −0
  • internal/docs/static/editor.5f97a8a3f0435f06.js +15 −0
  • internal/docs/static/editor.d2f04e050b943ff1.js +0 −15
  • internal/docs/views/show.html +251 −2
  • internal/export/document.go +40 −4
  • internal/export/markdown.go +46 −0
  • internal/export/markdown_test.go +65 −0
  • internal/export/pdf.go +55 −1
  • internal/export/pdf_test.go +63 −0
.amadan/ci.d/10-gate

Loading diff…

.gitignore

Loading diff…

core/src/fragment.rs

Loading diff…

core/tests/fragment.rs

Loading diff…

docs/superpowers/specs/2026-09-02-word-processor-basics-design.md

Loading diff…

fixtures/browser-drive.mjs

Loading diff…

fixtures/editor-entry.js

Loading diff…

fixtures/generate-fragment.mjs

Loading diff…

fixtures/links.mjs

Loading diff…

fixtures/links.test.mjs

Loading diff…

fixtures/menu.mjs

Loading diff…

fixtures/ruler.mjs

Loading diff…

fixtures/ruler.test.mjs

Loading diff…

fixtures/vectors/fragment/linked.bin

Loading diff…

internal/docs/handlers.go

Loading diff…

internal/docs/menu.go

Loading diff…

internal/docs/render.go

Loading diff…

internal/docs/ruler_test.go

Loading diff…

internal/docs/static/editor.5f97a8a3f0435f06.js

Loading diff…

internal/docs/static/editor.d2f04e050b943ff1.js

Loading diff…

internal/docs/views/show.html

Loading diff…

internal/export/document.go

Loading diff…

internal/export/markdown.go

Loading diff…

internal/export/markdown_test.go

Loading diff…

internal/export/pdf.go

Loading diff…

internal/export/pdf_test.go

Loading diff…