Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,53 @@ Decision. Why. Pointers.

---

## 2026-09-10 — bento/spaces footnotes: the reference is a TEXT TOKEN, and the number is derived

**Decision.** A footnote in `bento/spaces` is `doc.footnotes` (a document-level
map, label → inline html — bento/type's shape) plus the literal text `[^label]`
inside a block's `html`. The number a reader sees is derived at render time from
order of appearance, per page, and is never stored.

**Why a text token rather than an anchor.** bento/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()` reorders
mark nesting at every typing-run close, `sanitizeInline()` unwraps and strips on
every read of untrusted html, and the CRDT merges `html` as one register, which
an offset in a second register cannot merge with.

The other candidate was an inline marker element (`<sup>`, or an `<a href="#fn/…">`
with a new scheme in `HREF_OK`). Rejected because a new allowlist entry is a
ONE-WAY DATA HAZARD, which sanitize.ts's own comment on `HREF_OK` already spells
out: a reference written by this build would be STRIPPED, silently, by every
build shipped before it, on the first edit that touched the block. A text token
is round-tripped byte-for-byte by builds that already exist — verified by
loading a footnoted document into a shell built from the previous release.

**Why the number is derived.** Footnotes renumber on insertion, so a stored
number is wrong the moment a sentence moves and nothing says so. Same rule as
calc.ts's magic notes and slides' dynamic fields: store the token, derive the
output. The label is therefore an identifier, not a number, exactly as in pandoc
and Obsidian — which is also why the markdown round trip is the identity
function on the reference half.

**Consequences a future session should not treat as bugs.** (1) While a block is
being EDITED the author sees `[^1]`, not a superscript — injecting marker markup
into a contenteditable host puts it one keystroke from being committed into
`html` (`host.innerHTML` is written to the model on every `input`), which loses
the reference and stores a literal "1". The derived form is drawn in reading
view, print and the file-manager still. (2) A dangling reference is still
numbered and gets an empty row, because that is the authoring gesture and
because what is missing is the note, not the reference. (3) An orphaned note is
reported, never deleted. (4) `[^…]` inside a `code` block is not scanned.

**Pointers.** `spaces/src/footnotes.ts` (the whole argument, at length),
`spaces/CHANGELOG.md`, `docs/spaces-agents.md` §Footnotes, rigs in
`scripts/test-spaces-model.ts` and `scripts/test-spaces-agent.ts`.

---

## 2026-08-19 — Cross-app embedding: static render + source, never a second renderer

**Decision.** One block/element shape, `bento/embed`, shared by every app in both
Expand Down
43 changes: 43 additions & 0 deletions docs/spaces-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,42 @@ Links are same-document fragments:

`href` must match `^(https?:|mailto:|#p/)`. Anything else is stripped.

## Footnotes

A footnote reference is the literal text `[^label]` inside a block's `html`,
and the notes live in one document-level table:

```json
{
"pages": [{ "id": "p1", "title": "Coffee", "blocks": [
{ "id": "b8", "type": "p", "html": "Coffee grows in the tropics.[^1]" }
] }],
"footnotes": { "1": "Between Cancer and Capricorn." }
}
```

The reference is **text, not markup** — no tag, no attribute, nothing for the
sanitizer to allow — so it survives every edit and every sanitize pass exactly
the way the word beside it does, and a build that predates footnotes shows the
sentence with `[^1]` in it and round-trips the `footnotes` key untouched.

A label is `[A-Za-z0-9_-]{1,32}`. It is an **identifier, not a number**: notes
are numbered by order of appearance and the number is derived when the page is
drawn, so inserting a reference earlier on the page renumbers everything after
it and nothing in the file changes. Never write a number into the model and
never expect `[^1]` to render as 1.

Numbering is **per page** — the page is what prints and what a reader reads.
The section at the foot of a page is derived too: it is that page's references,
in order, so there is no block to add and nothing to keep in step. A note's
value is inline `html`, under the same allowlist as a block's.

`[^label]` inside a `code` block is left alone. It is not scanned in one and
never becomes a reference.

Markdown import and export both speak `[^1]` and `[^1]: the note.`, so an
Obsidian or Pandoc vault keeps its footnotes in both directions.

## The issue tracker

**An issue is a page.** There is no issue type and no flag: a page carrying a
Expand Down Expand Up @@ -357,6 +393,13 @@ markup inside inline `html` (and markup that is dropped whole), hrefs outside th
allowlist, images with no `alt`, no size, a missing `asset:` or a remote `src`,
a `home` naming nothing, pages with no blocks, and assets nothing references.

On footnotes it adds `dangling-footnote` (**warning**: a `[^label]` with no
note behind it — the reference still renders, numbered, into an empty note),
`orphan-footnote` (**info**: a note in `doc.footnotes` that nothing references,
so it is never numbered and never printed — it is kept, never deleted) and
`unreachable-footnote` (**warning**: a label outside the grammar above, which
no `[^label]` can ever match).

On the tracker it adds: `prop-html-stale` (a value whose readable `html` says
something else — the check worth running after any hand edit),
`unknown-field-value` and `unknown-field-key` (**info**, because that is how a
Expand Down
51 changes: 51 additions & 0 deletions scripts/test-spaces-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,5 +924,56 @@ const issue = (id: string, values: Record<string, unknown>, extra: Record<string
'every field is coerced to the type the report promises')
}

// ---- footnotes: both broken shapes are REPORTED and neither throws ---------
//
// An orphaned note (the sentence went, the note stayed) and a dangling
// reference (the note went, the marker stayed) are the two ways a footnote can
// come apart, and BOTH are invisible from the page: the prose still reads, the
// file still parses, and nothing is missing that an author would notice. That
// is precisely the shape of thing a validator exists for.
{
const doc = load([
p('p1', [
{ id: 'b1', type: 'p', html: 'A claim.[^1] Another.[^gone]' },
]),
], { footnotes: { '1': 'the note', spare: 'nobody points at me', 'a b': 'unreachable' } })

const v = validateDoc(doc)
const codes = new Set(v.findings.map((f) => f.code))
ok(codes.has('dangling-footnote'), 'a reference with no note behind it is reported')
ok(codes.has('orphan-footnote'), 'a note nothing references is reported')
ok(codes.has('unreachable-footnote'),
'a label no [^token] could ever match is reported — the note is stranded, silently')

const dang = v.findings.filter((f) => f.code === 'dangling-footnote')
ok(dang.length === 1 && dang[0].block === 'b1' && dang[0].page === 'p1',
'the dangling one names the page and block it is in, so it can be found')
ok(dang.every((f) => f.severity === 'warning'),
'and it is a WARNING, not an error — no word was lost, only the connection')
ok(v.findings.filter((f) => f.code === 'orphan-footnote').every((f) => f.severity === 'info'),
'an orphaned note is INFO: it is still somebody’s writing, sitting in the file')
ok(v.findings.filter((f) => f.code.endsWith('footnote')).every((f) => !!f.fix && !!f.message),
'every footnote finding says what is wrong AND how to fix it')

// NEITHER MAY EVER THROW. `doc.footnotes` arrives in a file somebody mailed
// you, so every shape a hand edit or a generator can produce has to be inert.
for (const bad of ['yes', 7, null, [], { a: 5 }, { a: null }]) {
let threw = false
try {
validateDoc(load([p('p1', [{ id: 'b1', type: 'p', html: 'x[^a]' }])], { footnotes: bad }))
} catch { threw = true }
ok(!threw, `validate() survives footnotes: ${JSON.stringify(bad)}`)
}

// a document with matched references and notes reports NEITHER — a validator
// that cries wolf on good documents is one an agent learns to skip
const clean = validateDoc(load([
p('p1', [{ id: 'b1', type: 'p', html: 'A claim.[^1]' }]),
], { footnotes: { '1': 'the note' } }))
const cleanCodes = new Set(clean.findings.map((f) => f.code))
ok(!cleanCodes.has('dangling-footnote') && !cleanCodes.has('orphan-footnote'),
'a document whose footnotes all match reports nothing about them')
}

console.log(`\n${checks - failures}/${checks} checks passed`)
if (failures) process.exit(1)
Loading
Loading