Skip to content

spaces: footnotes — text-token anchor, numbers derived at render time - #438

Open
nyblnet wants to merge 1 commit into
mainfrom
spaces-footnotes
Open

spaces: footnotes — text-token anchor, numbers derived at render time#438
nyblnet wants to merge 1 commit into
mainfrom
spaces-footnotes

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Footnotes in bento/spaces. New logic is in a new file, spaces/src/footnotes.ts; the diffs in render.ts, editor.ts, markdown.ts, model.ts and portable.ts are localized, because nine sibling branches are in those files.

The anchor, and why it is this one

doc.footnotes is a document-level, label-keyed map of inline html — bento/type's shape (type/src/model.ts), for its reason: a note has to outlive the paragraph pointing at it.

The anchor is where spaces cannot follow type. type anchors a reference by character offset into a block's text runs, and can: it owns the run list and rewrites every offset in one place. A spaces block carries html, and an offset into html is a position in one particular serialization of a position. Three things move it without changing a word of the prose:

  • canonicalize() runs at every typing-run close and reorders mark nesting, coalesces runs, drops empty ones;
  • sanitizeInline() runs on every read of untrusted html, unwrapping <p>, stripping a stale href, filtering class tokens;
  • the CRDT merges html as one register, which an offset in a second register cannot merge with.

So the reference is stored in the html, as text: [^label].

  • It moves with the prose for free — it is characters, so typing, bolding, merging blocks, sanitizing, canonicalizing and merging all carry it exactly the way they carry the word beside it. There is no offset to keep in step.
  • It needs nothing from sanitize.ts — no tag, no attribute, no href scheme. That is not tidiness. sanitize.ts's own comment on HREF_OK spells out why a new allowlist entry is a one-way data hazard: a reference written by this build would be silently stripped by every build already on disk, on the first edit that touched the block. The alternative designs (<sup class=…>, <a href="#fn/…">) all require one.
  • An older build reads it. [^1] is the markdown/pandoc source form. Verified by loading a footnoted document into a shell built from origin/main: the prose renders readably and doc.footnotes round-trips byte-for-byte.
  • Markdown is the identity function on the reference half, and ⌘F/search/textOf() see it because it is text.

The cost, stated: while a block is being edited you see [^1], not a superscript. That is deliberate — host.innerHTML is written to Block.html on every input, so marker markup injected into a contenteditable host is one keystroke from being committed, at which point the note is a literal "1" and the reference is gone. The derived form is drawn in reading view, print and the file-manager still, the way calc.ts draws an answer where it cannot be typed into.

Numbering is derived, per page

Order of appearance, computed in renderBlocks, never stored — calc.ts's rule and slides' dynamic-field rule. The label is an identifier, not a number (pandoc and Obsidian agree), which is also what makes the markdown round trip lossless.

Authoring

Type [^1] in a sentence; a numbered, empty slot appears at the foot of the page to write the note into. No new menu, no new gesture — the token is the gesture, the same opt-in calc.ts argues for. Emptying a note deletes the key rather than storing "".

Verification

node scripts/test-spaces.mjs        all 8 rigs green
                                    model 970/970, agent 189/189, journal 45/45 ×4 TZ,
                                    calc 90/90 ×4 TZ, undo 25/25, invite 34/34,
                                    roundtrip 6/6, size ok
cd spaces && tsc --noEmit           clean
npm run build:single                600KB → 274KB
node ../scripts/shell-gate.mjs      splice contract OK
node scripts/build-spaces-i18n.mjs --check   600 strings × 8 locales, all complete

Shell cost: 3,176 bytes — 280,945 against 277,769 for origin/main built in a temporary worktree with the same toolchain.

Behavioural assertions, imported and run (not source greps — this rig has twice watched a source grep pass through a live regression). Each of the three load-bearing ones was sabotage-verified: broken, watched fail, restored.

sabotage result
freeze one footnote's number (store-like) 2 FAIL, 968/970
stop lifting [^1]: definitions out of markdown 9 FAIL, 961/970
silence both validate reports 3 FAIL, 186/189

Browser-verified on a uniquely-named copy over http://127.0.0.1:5321, with a build-marker check first — which earned its keep immediately: the tab pool is shared with sibling agents and one navigated my tab away mid-check, so the first measurement was of a different app entirely.

Measured with getBoundingClientRect:

  • Renumbering, rendered. With a third reference inserted above two existing ones, [^1] renders as 2 and [^tea] as 3, while block.html still says [^1].
  • The marker does not disturb the line. A paragraph carrying two markers and one carrying none are both 28px tall (line-height: 0 on the raised box). Marker box 7.7 × 14.5px, raised above the paragraph's vertical middle.
  • Every href="#spfn-…" resolves to a real element; no raw [^ is left anywhere in reading view; the section sits below the prose.
  • Editor: the token stays text and no .sp-fnref exists inside any editable host. Typing into a note writes doc.footnotes; emptying it deletes the key; the other notes are untouched.
  • Save: the spliced #bento-doc carries the raw tokens and the footnotes key, and no derived markup is ever serialized.

What is NOT verified

  • Print on paper. The print path is renderPage(…, {editable:false, printing:true}) — the same call reading view makes, which is measured — and the @media print rules are confirmed present in the built shell's CSSOM. Nobody printed a page.
  • The section is repainted on blur when a page's footnote signature changes, not on input — repainting per keystroke would take the caret with it, since half of [^1 is a signature change too. So a reference typed and not yet blurred has no slot for a second or two.
  • Concurrent editing of the same note under collab is last-writer-wins, exactly as a table's rows is.

Flagged

  • scripts/test-spaces-model.ts and scripts/test-spaces-agent.ts are ops surface, edited under the file's-zone rule. No new rig file and no ci.yml step — both are already registered, and the contract says not to add a CI step while the queue is contended.
  • docs/spaces-agents.md gains a Footnotes section and three validate codes; docs/DECISIONS.md gains the anchor decision; spaces/CHANGELOG.md under [Unreleased], appended at the tail.

A footnote reference is the literal text `[^label]` in a block's html and the
notes live in a document-level `doc.footnotes` map (bento/type's shape, so a
note outlives the paragraph pointing at it). The number is derived from order
of appearance when the page is drawn and is never stored.

The anchor is the part that could not be copied from type. type anchors by
character offset into a block's `text` runs; a spaces block carries `html`, and
an offset into html is moved by canonicalize() reordering mark nesting, by
sanitizeInline() unwrapping and stripping on every read, and by the CRDT
merging html as one register. A text token moves with the prose through all
three because it IS the prose. It also needs no new tag, attribute or href
scheme — and a new allowlist entry would be a one-way data hazard, stripping
this build's references in every build already shipped.

Numbering is per page and derived, so inserting a reference above two others
renumbers them with nothing in the file changing. Verified in the built shell:
`[^1]` renders as "2" while block.html still says `[^1]`.

Markdown round trips both halves — references pass through untouched, `[^1]:`
definitions are lifted out before the block parser can downgrade them to
paragraphs, and the exporter writes them back per page. Imports of several
files deconflict colliding labels and rewrite that file's references; the
subtree graft does the same.

validate() reports a dangling reference (warning, with its block), an orphaned
note (info, kept never deleted) and a label outside the token grammar. Neither
can throw on a hand-edited `footnotes` field; every lookup uses Object.hasOwn.

Print: the section is endnotes at the foot of the document page, kept whole
across a sheet break. `float: footnote` is the typographic answer and no
browser implements it, so it would silently not print at all.

Costs 3,176 bytes on the shell.
@github-actions

Copy link
Copy Markdown

Build size

main (0100083) → spaces-footnotes (ccd98f7)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 274.4 KiB +3.1 KiB (+1.14%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-10T00:22:13Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant