Bento is growing from one app (Slides) into a suite (Spaces, Dash, …). This document is the contract that makes them all Bento: the properties a file must keep so that shipped documents — including ones saved years ago — continue to open, save, update, and sync. Breaking an invariant here doesn't break the build; it bricks files that are already on users' disks.
slides/ is the reference implementation for everything below. When this doc
and the code disagree, the code that shipped wins — fix the doc.
A Bento document is a single self-contained HTML file carrying the document
data, the viewer, and the editor. It must work from file://, from a static
host, and from an email attachment — no backend, no CDN, no network required
to open, edit, present, or save. Anything that adds a runtime network
dependency to the core document lifecycle is off-platform.
Byte order of a shipped shell (postbuild-compress):
chrome → NOTICE → tooling comment → plaintext #bento-doc → splash → compressed payloads.
Runtime JS/CSS ship deflated in bento/deflate-b64 script blocks with a ~1KB
loader (DecompressionStream → blob import).
Self-save and self-update work by re-splicing the document block into a shell. Updaters embedded in already-shipped files are frozen code, so every future build of every app must keep:
- a
<script type="application/bento+json" id="bento-doc">block that is plaintext (never inside the compressed payloads), same id, forever; - block content that is JSON with
<escaped as\u003c— it can never contain</script>; - the SAME escaping on every other plaintext data block the shell carries
(
application/bento+*, written byregisterShellBlocks— language packs today): their bodies are not authored by us, so an unescaped one could close its own block or forge a second#bento-docopening tag for an old updater to splice into; - a file that survives
DOMParser → splice → outerHTMLround-trips, with balanced script tags and a v0.1.0-style text splice still producing a well-formed document.
scripts/release.mjs runs a conformance gate on all of this before signing.
New apps must run the same gate (or an app-specific equivalent with identical
checks) before any release.
doc.formatnames the format (bento/slides,bento/spaces;bento/dashto come) with an integer format version indoc.version. The field isformat—slides/src/model.tsexportsFORMAT = 'bento/slides'and writes it asformat, and every reader keys off that.- Formats are additive. Every version opens files from every earlier version; unknown fields are preserved, not stripped. Breaking reads of old files is not an option — there is no server to migrate them.
docId(uuid) is minted once at creation/load and never regenerated. It keys autosave recovery, collab identity, and future sync/merge. "Duplicate as new deck"-style flows mint a freshdocId+ fresh collab creds — that is the only sanctioned way an id changes.- Locale/language never enters the document format; i18n follows the viewer.
- Self-save: capture the pristine shell at boot, swap the
#bento-docblock, re-serialize. File System Access API first, download fallback. - Runtime-injected DOM must be marked
data-bento-transient. The pristine capture clones the LIVE document, so anything the runtime adds before it — the compressed shell's inflated stylesheet, first of all — would otherwise be written into the saved file, then re-injected on the next boot and saved again: unbounded growth, one copy per save (~100KB each for the CSS, which ships deflated for a reason).serializeBodystrips the marked nodes from every serialized shell. Inject before the capture only if you mark it —scripts/shell-gate.mjsproves both halves on every CI build (the loader marks what it injects; the runtime still strips it) and rejects a shell that carries any payload's content as plaintext. - Autosave (IndexedDB) keeps a latest-recovery snapshot + a capped version
timeline, keyed by
docId. Read-only players skip autosave. - Password-protected docs use the
bento/encenvelope (PBKDF2-SHA-256 300k → AES-GCM-256 over the doc JSON) inside the plaintext block — the splice contract still holds. Encrypted docs are never snapshotted to IndexedDB in plaintext, and every write-back path stays encrypted while the password is held in memory.
Authoritative spec: docs/collab-design.md. The non-negotiables:
- The relay stores/relays ciphertext only (AES-GCM; key in
doc.collab.key, never sent to the server). Room ids are random or key-committed — never thedocId, never derived from content. - The saved file is the capability: opening a copy joins the session.
Reader copies strip private keys (
writerPriv,ownerPriv, invites); read-only is enforced cryptographically by the relay, not honour-system. - Credentials are minted at creation but a never-saved/never-shared doc stays dormant — a fresh template or demo must never phone home.
- Engine changes (
sync/crdt.ts) require the convergence rig (node scripts/test-sync.ts) before merge. No exceptions. - Relay changes require
wrangler deployand must stay backward-compatible with already-shipped clients (deploy relay before client when a handshake changes).
- Shipped files check
https://bento.page/releases/<app>/manifest.json(user-initiated or launch check) and verify: ECDSA P-256 signature over the manifest payload against thePUBLIC_KEY_JWKembedded in the shell, sha256 of the fetched shell, and version monotonicity. - Manifest shape:
{ payload: "<json string>", sig: "<b64>" }where payload carries{ app, version, sha256, url, at }. - The signing key lives offline (
~/.bento/release-key.json), never in the repo or CI. Releases are cut locally so the signed bytes are the served bytes (docs/RELEASING.md). Updates write a NEW file or keep an explicit FSA handle — the original stays as rollback. - All apps share the release channel pattern; each app gets its own manifest
path under
releases/.
The document JSON is the interchange unit for AI tooling — chat models
can't emit multi-MB files. Every app exposes: copy document JSON / replace
document from JSON (undoable), plus a scripting surface on window.bento
(doc, serialize(), loadDoc(json), …). The shell carries a tooling
comment pointing agents at #bento-doc and this API. Keep model JSON pure
data — template strings over functions (see charts: formatters are {b}/{c}
templates, never code).
~1KB t() with English-string-as-key (missing key = English). Never call
t() at module scope (frozen at import). select() localizes display labels
only — model values stay English words. Audit with setLocale('x-pseudo').
Catalogs come in two tiers. A bundled core is compiled in — the per-locale
files under slides/src/i18n/ are the authored source, packed key-once into
packed.ts by scripts/build-i18n.mjs (CI gates that it is current). New UI
strings must land in all core catalogs in the same PR. Every other language
is a signed pack, released centrally and spliced into the file on demand;
see i18n-packs.md. Packs are additive — a file that never fetches one behaves
exactly as before — and both tiers fall back per string to the English key,
which is what lets a stale or partial pack degrade instead of break.
Four pack rules are platform-level, not app choices:
- A pack lives in the FILE and nowhere else. No browser-local copy: the
download comes from an https origin and the file is then opened from
file://, so anything stored per-origin vanishes on the journey the product encourages. A pack rides in anapplication/bento+langblock written byregisterShellBlocks(kernelsave.ts), under the same\u003c-escaping and the same §2 splice contract as#bento-doc—scripts/shell-gate.mjsproves a pack-carrying shell conformant, with an adversarial pack, on every build. - Adding is staged, and written on the next save. Writing on click means a silent second download of the user's deck on every browser without File System Access.
- Self-update carries packs forward and refreshes them for the incoming
version (
registerUpdatePreparein kernelupdate.ts), best effort — a pack that cannot be re-fetched is kept, never dropped. - Fetched packs are verified against the signed release channel; embedded packs are not re-verified — they carry the same trust as the document around them, and opening a file must never require the network.
Content direction belongs to the DOCUMENT and is per element. Text
resolves its own base direction from what the author typed (dir="auto" in
the renderer), so an Arabic paragraph reads RTL beside an English one in the
same file. This is data: it renders identically for every viewer, and it is
the half that fixes real bugs (misplaced sentence-final punctuation).
Chrome direction belongs to the VIEWER and never enters the format. The
editor flips to RTL when the viewer's locale is RTL — the same viewer-scoped
pattern as bento-lang and reduce-motion. applyDirection() runs AFTER
capturePristine(), so a saved file can never carry a dir attribute. Lay
chrome out with logical properties (inset-inline-start, margin-inline-end,
text-align: start) and it mirrors itself; only glyphs that encode a
direction in their SHAPE need flipping by hand.
INVARIANT — the document never mirrors. Elements carry absolute x/y model coordinates, so a document MUST render identically regardless of who opens it. A document whose appearance depends on the viewer's locale is a format-level bug, and a worse outcome than an unmirrored UI. Every surface that renders document content is therefore pinned
direction: ltr: the document root, thumbnails, the scroller, the present overlay, print, and any body-mounted overlay that reads coordinates (e.g. Moveable's control box, which mounts outside the document subtree and needs its own pin).An app adding a new document-rendering surface MUST pin it. The pin is also what keeps direction away from
scrollLeft— the one layout API whose meaning genuinely changes under RTL — so coordinate math stays untouched.
Shared — kernel/src/, extracted and in use (evolve carefully, serialize
changes — see docs/PARALLEL-WORK.md): save.ts (splice + bento/enc
encryption), autosave.ts, update.ts, anim.ts, charts.ts, the i18n.ts
engine, app.ts (per-app identity via configureApp), doc.ts (the
KernelDoc envelope). Apps import these through facades at their own paths.
The CRDT engine is kernel/src/sync/crdt.ts now. It takes its document shape
as a DocShape at construction (two property names: the doc key holding the
parent array, the parent key holding the child array), so one algebra serves
bento/slides and bento/spaces with neither app's vocabulary in the kernel. An
app imports its own binding — slides/src/sync/crdt.ts → SLIDES_SHAPE —
never the engine directly.
Also shared but NOT yet in kernel/: the session/transport layer
(slides/src/sync/session.ts, online.ts, blobs.ts) and the relay
(server/). Those are still slides-shaped — treat them as kernel-zone for
serialization.
Shared build tooling: scripts/postbuild-compress.mjs (parameterised per app
via --generator / --title) and scripts/shell-gate.mjs.
Per-app (own it, don't prematurely abstract): the document model, the renderer, the editor UX, starter documents, panels.
A new Bento app is on-platform when it:
- builds to ONE self-contained HTML file passing the §2 conformance gate
- declares
doc.format+doc.version; opens its own older files - mints and preserves
docIdper §3 - self-saves (FSA + download) and autosaves per §4
- supports the
bento/encenvelope per §4 (or explicitly documents why not yet) - ships collab dormant-by-default per §5, or ships without collab wired rather than with a half-secure version
- verifies signed updates per §6 with its own manifest path
- exposes the AI round-trip surface per §7
- uses the shared i18n runtime per §8
- has a starter document that demos the app honestly
- documents its model in its own CLAUDE/README section