Normative reference for the JSON document model, current as of bento/slides
v1.0.6 (format version 1). The authoritative source is
slides/src/model.ts — this document tracks it. If
the two disagree, the code wins; please file that as a docs bug.
A .bento.html file carries one JSON document inside a single plaintext block:
<script type="application/bento+json" id="bento-doc">
{ "format": "bento/slides", "version": 1, ... }
</script>Everything else in the HTML file is the fixed shell (the runtime, styles, license notices). The document block is the only part that changes between saves. See architecture.md for how the file is built and saves itself; this document specifies the JSON.
This spec is for people writing tools, generators, or agents against the format. If you want to author a good-looking deck, read agents.md — it maps content to the right feature. This one is the dry reference of every field.
- Coordinates are pixels in the slide space defined by
doc.size(default1280 × 720, a 16:9 canvas).x,yis an element's top-left corner. - Colors are any CSS color string, including
rgba(...)for alpha. - Angles are degrees. Rotation is clockwise. Gradient angles follow the CSS
convention (
0= bottom→top,90= left→right). - Ids are identity. Elements that share an
idacross adjacent slides morph into each other; slidestateOfand elementlinkboth reference slide ids. Generators must emit deterministic, stable ids. - Additive & forward-compatible. Unknown fields are preserved through parse → serialize. Old files must open in newer shells. Never repurpose or remove a field; add optional ones.
- Pure data. Text HTML is sanitized to an inline whitelist; chart options are pure JSON (no functions). A document can never carry executable code.
- Self-contained. Everything a view needs is in the file. External
references (image/media URLs) are allowed but break the offline guarantee;
embedded
data:/asset:sources keep it intact.
When writing the JSON into the file block, escape every < as \u003c so
the string </script> can never appear and terminate the block.
| Field | Type | Required | Notes |
|---|---|---|---|
format |
"bento/slides" |
yes | Format discriminant. Check it before editing. |
version |
number |
yes | Format version. Current: 1. |
docId |
string (uuid) |
yes* | Stable per-document identity, minted at creation. Never regenerate it. *Minted on load if a pre-docId file lacks one. |
title |
string |
yes | Deck title; also synced to the HTML <title>. |
size |
{ width, height } |
yes | Slide coordinate space in px. Default 1280 × 720. |
theme |
Theme |
yes | Deck-wide defaults (see below). |
slides |
Slide[] |
yes | Linear order; state slides sit right after their parent. Must be non-empty. |
modified |
string (ISO) |
yes | Last-modified timestamp. |
present? |
{ slideNumber?, controls?, progress? } |
no | Present-mode Reveal chrome toggles. |
assets? |
Record<string,string> |
no | Shared blobs (raw SVG markup or data: URIs), referenced by key as "asset:<key>". |
fonts? |
Array<{ family, asset, weight?, style? }> |
no | Embedded @font-faces injected at boot; asset is a key into assets (a woff2 data URI). |
layouts? |
Slide[] |
no | Slide-shaped templates (see Layouts). Absent = the built-in starter layouts are offered. |
collab? |
Collab |
no | Live-collaboration credentials + CRDT state (see Collaboration fields). |
template? |
boolean |
no | Template file: every open mints a fresh docId and drops collab (see File modes). |
readonly? |
boolean |
no | Player file: boots straight into the presentation, no editor. |
| Field | Type | Notes |
|---|---|---|
background |
string |
Default slide/canvas background. |
color |
string |
Default text color. |
accent |
string |
Single accent color; also seeds derived chart palettes. |
fontFamily |
string |
Default font stack. |
chartPalette? |
string[] |
Ordered series colors for new charts. Absent = derived from accent. |
table? |
Partial<TableStyle> |
Defaults for newly inserted tables. Omitted properties use the standard table style; existing tables retain their own style. |
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string |
yes | Stable slide id. Link + morph targets reference it. |
background |
string |
yes | Slide background (CSS color). |
transition |
TransitionKind |
yes | none | fade | slide | zoom | morph. morph tweens matched element ids from the previous slide. |
elements |
SlideElement[] |
yes | Array order = paint order (z). First element is at the back. |
notes |
string |
yes | Speaker notes (travel in the file; shown in speaker view). |
name? |
string |
no | Friendly label for link pickers / state badges. |
stateOf? |
string |
no | Marks this slide a hidden state variant of the slide with this id (see Interactive states). |
hover? |
{ type, dim?, default? } |
no | Present-mode hover behaviour: type: 'focus-group' (dim elements outside the hovered group) or 'reveal' (showOnHover set swap; default names the resting set). |
comments? |
Comment[] |
no | Review threads. Editor-only — never rendered in present/print, but saved in the file. |
{ id, author, text, at (ISO) } plus an optional anchor and thread:
- Anchor:
elementId?(thread pinned to an element), orx?/y?(a point in slide coordinates), or neither/dangling (the whole slide). resolved?: boolean;replies?: Array<{ id, author, text, at }>.
window.bento.comments() returns the flat, typed-anchor list — the entry point
for tooling that processes flagged issues in a deck.
Every element carries the common ElementBase fields, plus type-specific ones.
| Field | Type | Notes |
|---|---|---|
id |
string |
Identity. Shared ids across adjacent slides morph. |
x, y, w, h |
number |
Frame in slide px (top-left origin). |
rotation |
number |
Degrees, clockwise. |
opacity |
number |
0..1. |
shadow? |
ShadowSpec | ShadowSpec[] |
Drop shadow(s): { x?, y?, blur, color }. An array stacks (e.g. elevation + glow). Follows the element's alpha shape. |
fx? |
Fx |
Presentation behaviour — runs in present mode only (see fx). |
link? |
string |
While presenting, clicking jumps to this slide id. |
group? |
string |
Semantic group tag — hover focus + multi-element present behaviours target it. |
groupId? |
string |
Editor grouping (select/move as one). Distinct from group. |
showOnHover? |
string |
In-slide hover reveal: visible only while an element whose group equals this value is hovered (with slide.hover.type='reveal'). |
role? |
string |
Layout role (title/subtitle/body/kicker by convention) — drives cross-layout content moves. Free-form. |
The element type discriminant is one of: text, shape, image, svg,
chart, table, media.
type: "text" — html (sanitized inline subset: b/i/u/s/code/br/span),
fontSize, fontFamily, fontWeight, color, align (left|center|right),
valign (top|middle|bottom), lineHeight. Optional letterSpacing (px) and
placeholder (a dimmed prompt shown while html is empty; hidden in
present/print).
Text also resolves dynamic-field tokens at render time — {{page}},
{{pages}}, {{title}}, {{date}}, {{time}} — with an optional zero-pad
width on page/pages ({{page:2}} → 06). The model stores the raw token;
only the rendered output is resolved, so numbering re-flows when slides move.
type: "shape" — shape: rect | ellipse | triangle | arrow | line | path,
plus fill, stroke, strokeWidth, radius (rect corner). Options:
fillGradient?:{ angle, stops: [{ at: 0..1, color }] }— linear gradient; when set it wins overfill(kept as the solid fallback).strokeStyle?:solid | dashed | dotted(wins over legacystrokeDash?).- line shapes take their color from
fill, draw horizontally across the box (rotate for vertical), and accept tipslineStart?/lineEnd?=none | arrow | dot | bar. - path shapes carry
d?(SVG path data) authored in thepathBox?viewBox[x, y, w, h].
type: "image" — src (a data: URI or "asset:<key>"), fit
(contain|cover|fill), radius. Embed images as data: URIs in doc.assets
and reference them to keep the file self-contained.
type: "svg" — asset? (key into doc.assets holding raw SVG markup;
preferred, dedupes) or markup? (inline SVG). css? is injected inside the
svg and scoped to it at render (hover/focus/animation styles stay contained).
Prefer composing native shape/text/path elements when they can morph;
use svg for static artwork/geography.
type: "chart" — option is a pure-JSON, ECharts-shaped option object
(the format is ECharts-compatible; the renderer is the in-house charts-lite
engine, not ECharts). Static SVG snapshots on the editor canvas / thumbnails /
print; a live interactive instance (tooltips, wheel-zoom, drag-pan) while
presenting.
preset?:bar | line | pie | scatter— the panel's re-seed key.source?:{ tableId }— live binding; the chart's labels + series values track that table element (data only; styling/axes preserved).
Chart rules that bite:
- Bar/line series
datamust be plain numbers.{value, itemStyle}item objects coerce to0; only pie takes{name, value}. - Color by series, not per bar — per-item bar colors are unsupported.
- Formatters are template strings only (
{b},{c},{d}) — never functions (they can't serialize). - Dual y-axis:
yAxismay be an array of two{type:"value"}axes; a series selects one viayAxisIndex: 0|1. Give the second axisaxisLabel:{formatter:"{value}%"}for a percentage scale. - Unknown option keys degrade gracefully (ignored, never fatal).
type: "table" — a real HTML <table> (table-layout fixed), rendered
identically on canvas/thumbnails/present/print.
columns:Array<{ w }>— fractional column weights, normalised at render.rows:Array<{ cells: TableCell[] }>whereTableCell={ html, align?, color?, bg?, bold? }(htmlis the same sanitized inline subset as text).header:boolean— treat row 0 as a styled header.style:TableStyle={ headerBg, headerColor, zebra?, borderColor, borderWidth, cellPadX, cellPadY, fontSize, fontFamily?, color, radius }.
Cohesion lives in style; cells carry only overrides. Morphs as a box —
cell content does not morph. Under live collaboration rows is a whole-value
LWW register (concurrent different-cell edits are last-writer-wins).
type: "media" — kind: video | audio, src (a data: URI = embedded, an
external URL / relative path = referenced, or "asset:<key>"). Video also
takes poster? (data:/asset:/URL), fit? (contain|cover|fill),
radius?. Playback flags: controls?, autoplay?, loop?, muted?.
- Autoplay fires only in present mode (never on the canvas or in
thumbnails), and browsers require
muted: truefor a video to autoplay — sodefaultMediamutes video by default. - Embed only short clips. The editor warns above
MEDIA_EMBED_BUDGET(8 MB) and offers a URL instead — a big data URI makes the file slow to open and save.
All fx behaviour runs in present mode only.
| Field | Type | Meaning |
|---|---|---|
enter? |
'fade-up' | 'fade' | 'fade-down' | 'slide-left' | 'slide-right' | 'slide-up' | 'slide-down' |
Entrance animation. fade-* nudge ~16px; slide-* sweep ~120px from an edge. Only runs on non-morph arrivals. |
order? |
number |
Stagger step in the entrance sequence; equal values enter together. |
countUp? |
boolean |
Animate numeric parts of the text from 0 to their final value. |
ambient? |
'kenburns' |
Continuous ambient motion for full-bleed photos. |
ken? |
{ dir?, scale?, duration? } |
Ken-burns tuning. dir: 'drift' (default) is an endless slow yoyo zoom; 'out'/'in' play once per slide entry ('out' starts zoomed by scale and settles). duration in seconds. |
loop? |
dash-march or motion-path | Continuous loop (below). |
loop is one of:
{ type: 'dash-march', distance?, duration? }— marching dashed strokes.{ type: 'motion-path', path, duration, delay?, ease?, speeds? }— the element travels an SVG path relative to its rest position (the first anchor is0,0).easesets per-lap tempo;speeds[]gives per-anchor speed multipliers (1= normal,<1dwells,>1rushes; length matches the anchor count). Never combine a motion-path loop with an entrance tween.
A slide with stateOf: "<parent-id>" is a hidden variant of its parent:
- Skipped by linear navigation. Reached only by element
links. - While on a state,
ArrowLeftreturns to the parent,ArrowRightcontinues past it. - If it shares element ids with its parent (or a sibling state) and the
transition is
morph, the matched elements glide between the two.
State slides live adjacent to their parent in slides[] and render nested in
the editor sidebar. A clickable trigger should be a padded transparent hit
rect, not the label text itself.
doc.layouts holds Slide-shaped templates (plus the built-ins in model.ts).
Instantiating a layout keeps element ids — slides born from the same layout
share ids, so their common chrome morphs across transitions and stays traceable
for a re-apply merge.
Applying a layout to an existing slide matches donors first by id, then by
role (same element type required); the layout supplies frame + typography
while content (text html, link) rides along. Layout-owned leftovers are
dropped unless they are text someone actually wrote; user extras survive on top.
doc.collab is optional and additive; a file with no collab opens as a
standalone document forever. Full design and threat model:
collab-design.md.
| Field | Type | Notes |
|---|---|---|
room |
string |
Relay WebSocket URL. Room id is random — never derived from docId. Signed-scheme rooms start w; legacy rooms start r. |
key |
string |
base64url AES-GCM room key — the read capability. Travels in every copy. |
on? |
boolean |
Gates auto-join. Absent = true (v0.8.0 files only carried collab while actively shared). |
sync? |
SyncStateJSON |
CRDT state (registers / liveness / text) stamped at save on shared documents. Lets an offline-edited copy rejoin as a true fork and merge two-way. Never transmitted as ops. |
writerPub? |
string |
ECDSA P-256 public key (raw SPKI, base64url) — the write capability's public half. Travels in every copy; the relay verifies authorship against it. |
writerPriv? |
string |
ECDSA private key (PKCS#8, base64url). Travels only in writer copies. A read-only copy is a writer copy with this stripped. |
role? |
'writer' | 'reader' |
'reader' = a live viewer: receives updates, never sends. |
Possession of a copy is the capability; "Rotate keys" re-mints them to cut
old copies off (and upgrades a legacy r-room to an enforced w-room).
| Mode | Set by | Behaviour |
|---|---|---|
| Editable deck | (default) | Boots the editor; saves rewrite the file in place. |
| Template | template: true |
Every open mints a fresh docId and drops collab (parseDoc strips the flag) — each opener gets an independent deck. The template file itself never changes. |
| Player / presentation package | readonly: true |
Boots straight into the presentation; never shows the editor. Collab is stripped from the saved copy. |
| Read-only live viewer | collab.role: 'reader' (writerPriv stripped) |
Boots the editor locked; receives live updates but the relay drops any write. |
Encryption is orthogonal: the #bento-doc block may hold a bento/enc
envelope (PBKDF2-SHA-256 + AES-GCM over the doc JSON) instead of plaintext
document JSON — boot shows a password gate. The envelope is still plaintext
JSON in the block, so the splice contract holds.
format: "bento/slides"plus additive, optional fields — old files open in newer shells; unknown fields survive parse → serialize.- Element ids are identity — morph, states, and links all key off them. Generators must emit deterministic ids.
- The data-block JSON stays
<-escaped; text HTML and chart options stay pure data (no functions, sanitized HTML) — a document can never smuggle code. - Asset references are
asset:keys intodoc.assets; a self-contained file fetches nothing external at view time. - Motion paths are stored relative to the element's rest position; the first path anchor is that position by definition.
- Every document carries a stable
docId— never derive it from content, never regenerate it on save.
{
"format": "bento/slides",
"version": 1,
"title": "My deck",
"size": { "width": 1280, "height": 720 },
"theme": {
"background": "#101418", "color": "#F2F0EA",
"accent": "#FF9E8A", "fontFamily": "system-ui, sans-serif"
},
"slides": [
{
"id": "s1", "background": "#101418", "transition": "none",
"notes": "speaker notes here",
"elements": [
{
"id": "t1", "type": "text",
"x": 96, "y": 260, "w": 1088, "h": 160,
"rotation": 0, "opacity": 1,
"html": "Hello from a tool.",
"fontSize": 88, "fontFamily": "system-ui, sans-serif",
"fontWeight": 800, "color": "#F2F0EA",
"align": "left", "valign": "top", "lineHeight": 1.1
}
]
}
],
"modified": "2026-07-19T00:00:00.000Z"
}docId is omitted above for brevity — parseDoc mints one on load. When a
tool creates a document from scratch, generate a fresh uuid for docId.