# Bento — self-contained office documents
One HTML file = the document + viewer + editor. See `README.md` for the vision.
`slides/` is the first app (PowerPoint replacement); docs and sheets come later.
## Architecture (slides/)
- `src/model.ts` — the `bento/slides` JSON document model. This is the format.
- `src/starterdeck.ts` — the showcase starter deck (what a fresh build opens
with): four 'sd-tile-*' elements morph through EVERY slide (the id-continuity
demo), one deliberate 'fade' beat exists because entrance staggers/count-ups
only run on non-morph entries, charts slide + hidden pie state demo the
bar⇄pie data morph, speaker notes double as the feature tour. Gotchas learned
building it: line shapes take their color from `fill` (not `stroke` — the
stroke attr is what morphs tween), and the renderer draws lines horizontally
across the element box (vertical lines = rotation), keep 96px side margins
(x ≤ 1184 for right-most content).
- `src/save.ts` — the self-save trick: clone the document at boot (`capturePristine`),
swap the `#bento-doc` data block, re-serialize. JSON is `<`-escaped (`\u003c`) so it can
never contain ``. File System Access API first, download fallback.
- `src/autosave.ts` (v0.9.8) — auto-save + local version history, IndexedDB
(`bento-autosave`, two stores: `recovery` single-latest-per-docId, `versions`
capped timeline). Editor debounces (2.5s) on `doc` events: writes a recovery
snapshot (plain doc JSON, NOT the shell) + a throttled version, and — when a
FSA handle exists — silently rewrites the real file (`writeUpdatedFile`, shows
a "Saved" tag). On boot `checkRecovery` compares the latest snapshot's
`docContentKey` (content minus volatile modified/collab fields) to the loaded
doc; a mismatch shows a Restore/Discard banner. Encrypted decks are NEVER
snapshotted to IndexedDB (plaintext-to-disk) — their file write-back stays
encrypted. readonly players skip autosave. Version history UI in the About
dialog; restore = `store.replaceDoc` (undoable). Keyed by docId, so recovery
needs a stable docId (saved files) — the fresh-each-load anonymous demo won't
cross-reload-recover, by design.
- `src/render.ts` — single model→DOM renderer shared by editor canvas, thumbnails, and
Reveal sections. Elements carry `data-el-id` (editing) and `data-flip-id` (morph).
**Morph key (v1.0.7)**: `data-flip-id = el.morphId || el.id`. `id` stays the
stable identity (selection, connector/comment anchors, CRDT node key); the
DEFAULT morph key is still `id` (the duplicate-a-slide idiom, old files have no
`morphId`), but an optional `morphId` re-targets the pairing WITHOUT mutating
`id` — so two independently-created elements on different slides can morph. This
one line is the whole engine change; everything downstream reads `data-flip-id`.
Edited in the panel's Morph section (`panels.ts buildMorphProps`): a "Morph id"
field (writing the element's own id clears the override) + a "Pair with" picker
that adopts another slide element's key; both reject a key that would collide
with another element's effective key on the SAME slide (present.ts maps by flip
id, so same-slide dupes would break pairing).
**Dynamic fields (v0.9.12, doc-props v1.0.2)**: text resolves `{{page}}`,
`{{pages}}`, `{{title}}`, `{{date}}`, `{{time}}` plus the document-property
tokens `{{author}}`, `{{company}}`, `{{subject}}`, `{{event}}` at render time
(`resolveFields`); page/pages take a zero-pad width (`{{page:2}}`→"06"). The
doc-props live in optional `doc.meta` (`{author,company,subject,event,keywords}`
— additive, backward-compatible; old files lack it and tokens resolve empty)
and are edited in the About dialog's "Document properties" section (title stays
top-level). `renderSlide` auto-fills `RenderOpts.fields` via `fieldContext(doc,
slide)` (page = 1-based position among NON-state slides).
The MODEL stores the raw token; only output is resolved, so inserting/removing
slides re-numbers everything. Editing gotcha: the canvas renders resolved, but
`canvas.startTextEdit` swaps the token BACK to raw `el.html` while editing so
authors edit the field, not the computed value. The starter deck's furniture +
ghost numerals use `{{page:2}}` (they can't drift). Groundwork for the office
suite's field/cross-reference system.
- `src/editor/clipboard.ts` (v0.9.9) — system-clipboard copy/paste. Bento content
is written as JSON tagged `__bento:"clip"` (kind elements|slides) with referenced
assets/fonts embedded, so it round-trips across decks/tabs; asset-key collisions
remap. Editor: ⌘C copies selected elements, or the current slide when nothing is
selected (→ `navigator.clipboard.writeText`); a document `paste` listener handles
external images (embed as data-URI image element), plain text (→ text element),
and Bento payloads (insert elements on the current slide / slides after it, fresh
ids). Pasted slides drop `stateOf`. Guarded to skip when a text field is focused.
Also v0.9.9: a `?` help overlay (editor.openHelp — shortcuts + tips, topbar ?
button) and richer toolbar tooltips.
- `src/anim.ts` — in-house animation engine (no GSAP): to/fromTo tweens with
channels opacity/y/scale/color/strokeDashoffset/attr{}/motionPath, delay/
repeat/yoyo/ease, per-channel overwrite, killTweensOf/getTweensOf, manual
clock for tests (window.bento.anim). Transform channels compose via a
per-element registry that preserves the model's rotate() — call resetXform
after applyElementFrame or stale y/scale re-emerge on the next tween.
- `src/present.ts` — Reveal.js overlay; slides with `transition:'morph'` morph
matched `data-flip-id` elements MODEL-driven (both frames are in the doc —
no DOM measuring): translate+scale about top-left, PowerPoint scale mode.
Style props (fill/color) tween straight from the model values — including
gradients (stop colors + line coords tween; solid⇄gradient fabricates/
collapses a temp gradient; from-colors sampled at matching stop positions). Elements carry `fx` (enter stagger — equal
`order` = simultaneous, countUp, ken-burns (`fx.ken` dir drift/out/in + zoom %/secs;
out/in are one-shot settles per slide entry), `loop` dash-march/motion-path), `link`
(click → slide id) and `group`; slides can set `hover:'focus-group'` (dim other
groups). Present arrows are handled capture-phase (focus-proof). Editing UI for
fx/link lives in the panel's "Presenting" section.
**Reduced motion (v1.0.8)**: a VIEWER/PRESENTER preference (localStorage
`bento-reduce-motion` 'on'/'off', default = OS `prefers-reduced-motion`),
never in the doc — mirrors how locale is viewer-scoped. Toggle with `M` or the
⏸ speaker-view button (`setReduceMotion`/`toggleReduceMotion`). When on, the
fx call sites in `slidechanged`/init are gated OFF (no runMorph/runEnterFx/
runAmbientFx/restartSvgAnimations — elements render at their final frame,
count-ups show final values) and the `.reduce-motion` overlay class kills
CSS motion (Reveal section transitions + svg keyframe animations). Toggling
mid-show re-settles the current slide: kill tweens + applyElementFrame, and
replay enter/ambient fx if motion came back on. Motion-path loops are edited
visually on canvas (`editor/patheditor.ts`) — a HYBRID bezier editor built on
the shared `editor/bezier.ts` core (same exact-cubic engine as the shape-curve
editor). Waypoints are AUTO by default: their in/out tangents are auto-computed
(Catmull-Rom, byte-identical to the old `anchorsToPath`) so simple "arc through
these points" editing needs no handles. Selecting a waypoint REVEALS its bezier
control handles; dragging one flips that node to MANUAL — its tangents freeze
(baked from the current auto values, then dragged; smooth mirrors the opposite,
Alt breaks into a corner) and are stored explicitly, no longer auto-recomputed.
Untouched waypoints stay auto. Double-click the path inserts via de Casteljau
`splitSegment` (sub-pixel shape preservation; the 3 involved nodes go manual);
double-click a node removes it. On open, a path is parsed EXACTLY (no
re-sampling) and each node classified auto/manual by comparing its handles to
the Catmull-Rom tangents (legacy Catmull-Rom decks reopen fully auto; a bare
polyline is all-auto) — so the lossy sample→re-smooth round-trip drift is gone
and a path is byte-stable across open/save. The stored path is RELATIVE to the
element's rest position (first anchor = rest position; committing moves the
element there), serialized as explicit M/C cubics.
**Variable speed (v0.9.7)**: `fx.loop` motion-path takes `ease` (per-lap tempo,
panel dropdown) and `speeds[]` (per-anchor multipliers, one per anchor —
scroll a point in the path editor to set it; badge shows non-1 values). anim.ts
`samplePath(d, speeds)` warps time→arc-length: locate each anchor's arc-length
fraction (nearest sample), integrate 1/speed to a time LUT, invert — so the
element dwells at low-speed anchors and rushes at high ones. Uniform speeds =
identity (no cost); speeds omitted from the model unless they vary. `speeds`
stays 1:1 with the waypoints through every insert/remove/split because
`serializeBezier` emits exactly one on-curve point per node (so anim.ts
`onCurvePoints` recovers the same anchor list); split interpolates the new
anchor's speed from its neighbours.
- **Signed writes / read-only tiers (v0.9.18)**: three file modes now, split
from the old conflated `readonly`. (1) **Presentation package** = `doc.readonly`
(unchanged: PLAYER file, present-only, collab stripped) — "Save as
presentation package…". (2) **Read-only live viewer** = `collab.role:'reader'`
+ `writerPriv` stripped — "Save read-only copy…" (shown only when sharing is
on); boots the editor in a locked state (`store.readOnly` no-ops commit; remote
ops apply via session's direct state.apply+emit, NOT commit, so live updates
still land; `editor.enterReaderMode` hides the insert group + Moveable handles,
shows a pulsing banner; canvas text/cell edits early-return). (3) **Writer**
(default). ENFORCEMENT is cryptographic, not honour-system: `collab` gains an
ECDSA P-256 writer keypair (`writerPub` in every copy, `writerPriv` only in
writers) SEPARATE from the symmetric `key` (read cap). `mintCollab` is ASYNC
now and the room id COMMITS to the pubkey — `w`+b64url(sha256(pubRaw)) — so the
blind relay pins the writer key trustlessly (legacy rooms are `r`+random,
stay permissive). OnlineTransport signs op batches + snapshots (ECDSA over the
ciphertext `${i}.${d}`); the relay (server/sync-worker) verifies the
commitment at connect and DROPS any persisted frame lacking a valid sig →
readers (no priv) can't write. Client signs on the old relay too (extra fields
ignored) so there was no breakage window; **relay must be `wrangler deploy`d**
for enforcement (done). Async-mint ripple: `session.ensureCollab` (new docs
only, never auto-connected), startSharing/rotateKeys/saveAsNewDeck await.
Full spec + threat model in docs/collab-design.md.
- **File modes (v0.9.0)**: `doc.readonly` = PLAYER file (boots straight into
the show; exit lands on a minimal card, never the editor; "Save read-only
copy…" strips collab). Password encryption: the #bento-doc block can hold a
`bento/enc` envelope (PBKDF2-SHA-256 300k + AES-GCM-256 over the doc JSON) —
boot shows a password gate; the password is held in memory so ⌘S and
self-update keep writing encrypted (save.ts serializeAuto/serializeDocInto
are THE encryption-aware paths; serializeFile stays plain for tooling).
Splice contract intact: the envelope is still plaintext JSON in the block.
mintCollab now mints on:true (decks are eligible to be live from creation)
— BUT auto-connect-on-open is gated by SyncSession.shareEligible():
connect only if the doc ARRIVED carrying collab (a saved/shared file) OR
the user opted in this session (saved, or "Start live session" →
enableSharing()). A never-saved starter/template stays dormant so the
anonymous bento.page/slides demo and template tire-kickers never phone
home (v0.9.1 fix — v0.9.0 connected every visitor). "Stop sharing" opts
out; Offline mode hard-blocks regardless.
- `src/update.ts` — signed self-update: checks a release manifest at LAUNCH
by default (localStorage 'bento-auto-check'='off' disables; toggle in the
About dialog; found updates badge the topbar sync button) and on demand via
About/topbar (`bento.page`; dev override localStorage 'bento-update-url'),
verifies ECDSA P-256 signature against the embedded
PUBLIC_KEY_JWK + sha256 of the fetched shell + version monotonicity, then
re-splices the current doc into the new shell (`save.serializeWith`) and
downloads it as a NEW file (original untouched = rollback). APP_VERSION baked
from package.json via vite define. Private key: `scripts/keygen.mjs` →
`~/.bento/release-key.json` (offline, NEVER commit/CI); sign releases with
`scripts/sign-release.mjs`. Docs carry a stable `docId` (uuid, minted at
creation/load) — identity for future sync/merge; never regenerate it.
Scripting: `window.bento.updates.{version,check,build,apply}`.
- `src/i18n.ts` + `src/i18n/*.ts` — internationalization: ~1KB t() with
ENGLISH-STRING-AS-KEY (gettext style; missing key = English fallback),
{placeholder} interpolation, catalogs compiled in (ja, zh-Hans, zh-Hant, es,
fr, de, it — 7 locales + English fallback = 8 UI languages);
locale follows the VIEWER (navigator.language; localStorage 'bento-lang'
override; picker in About rebuilds the workspace). Language never enters the
document format. select() localizes DISPLAY labels only (values stay model
words). GOTCHAS: never call t() in module-level consts (frozen at import —
translate at render time); keys must match source EXACTLY (validate with the
extraction/diff script pattern in git history); setLocale('x-pseudo') audits
unswept strings. New UI strings must be added to ALL catalogs.
- `src/charts.ts` — charts-lite, OUR OWN engine (ECharts removed for size:
it was 630KB = 47% of the shell; git history has the integration). Same
3-function API (CHART_PRESETS/mountChart/chartSnapshotSvg) interpreting the
ECharts option SHAPE (format unchanged): bar/line/pie/scatter, nice-tick
axes, legend, axis/item tooltips, inside wheel-zoom+drag-pan, transitions
(same series types = numeric-leaf lerp of the whole option per frame;
type change bar⇄pie = staged fade+sweep). Pure SVG on anim.ts. Options stay
PURE JSON (template formatters {b}/{c}/{d}, never functions). Editor
canvas/thumbs/print use chartSnapshotSvg (cached); present mounts live
(host exposes `__bentoChart`). Unknown option keys are ignored gracefully —
exotic ECharts configs degrade, don't crash. Bar/line series data must be
PLAIN NUMBERS ({value,itemStyle} item objects coerce to 0 — only pie takes
{name,value}); per-item bar colors unsupported, color by series. New charts
(+ Chart, preset switch, table→chart) inherit the deck's palette:
`applyChartPalette` (model.ts) bakes `option.color` from `doc.theme.chartPalette`
(optional format field; the starter deck declares peach/steel) or, absent that,
`deriveChartPalette(accent)` (accent + a cool HSL counterpart, each tinted).
tableToChart makes ONE series per numeric column (commas/`%` stripped, blanks→0,
first column = x labels) — and when two columns sit on very different scales
(or one reads as `%`) it auto-splits them onto a DUAL axis: bars on the left,
the odd column as a line on a right-hand axis. **Dual y-axis (v0.9.6)**:
`option.yAxis` may be an ARRAY of two value axes; a series picks one via
`yAxisIndex` (0/1). renderCartesian computes a range per axis, shares gridline
rows (2nd axis labels on the right, its own nice scale via `fixedTicks`), and
honours per-axis `min`/`max` + `axisLabel.formatter` ('{value}%'). **Visual
chart editor** (panels.ts buildChartProps): structured UI over the option —
Type, Legend + Second-axis toggles, a Series list (name · bar/line · left/right
axis · colour · remove), per-axis min/max, and an editable categories×series
data grid (add/remove rows keep xAxis.data + every series.data in lockstep);
pie gets a slices grid. The raw-JSON textarea stays as the 'Advanced (JSON)'
escape hatch. **Live table binding (v0.9.8)**: `chart.source={tableId}` links
a chart to a table — table→chart sets it by default. `model.syncLinkedChart`
pushes the table's labels+numeric columns into the chart's option IN PLACE
(data only; styling/axes preserved), and editor `syncLinkedCharts` re-derives
on the current slide whenever a table changes (signature-guarded against
loops; each collab replica derives identically from the synced table so no
extra ops needed). Panel shows a link banner + Unlink. Shapes:
`strokeStyle` solid/dashed/dotted (legacy `strokeDash` still honoured);
line shapes have `lineStart`/`lineEnd` tips (arrow/dot/bar) rendered as
per-instance svg markers (sized in strokeWidth units, endpoints inset);
line color morphs tween the STROKE attr (lines paint stroke, not fill).
Elements carry optional `shadow` {x,y,blur,color} — rendered as CSS
drop-shadow in applyElementFrame (follows alpha shape: rounded corners,
glyphs, image cutouts); panel offers presets (subtle/soft/elevated/glow),
non-matching values show as 'custom'. Present-mode Reveal CONTROLS (corner
arrows) default OFF (doc.present.controls re-enables).
- **Tables (v0.9.3)**: `table` element — a real HTML `
` (table-layout
fixed) rendered by the shared render.ts (`renderTableHtml`), identical in
editor/thumb/present/print. Model: `columns` fractional weights, `rows` of
`{cells:[{html,align?,color?,bg?,bold?}]}`, `header` bool, `style` object
(headerBg/Color, zebra, borderColor/Width, cellPad X/Y, fontSize, color,
radius). Cells edit on canvas via contentEditable (canvas.ts editCellAt/
commitCellEdit, mirroring text edit; Tab/Enter navigate, Tab off the end
appends a row). Column widths drag via `.bento-col-handle` overlays
(updateTableHandles/startColResize — live DOM update during drag, commit on
release; needs real-mouse QA like Moveable). Panel: row/col steppers, header
toggle, style presets (Lined/Zebra/Boxed/Minimal), colours, and a
table→chart bridge (buildTableProps/tableToChart: first column = labels,
first numeric column = series). Morphs as a BOX (cell content does not
morph); under collab `rows` is a whole-value LWW register (concurrent
different-cell edits are last-writer-wins — documented limitation).
- **Media (v0.9.16)**: `media` element (`kind` video|audio) mirrors image.
HYBRID storage — `src` is a data: URI (embedded, self-contained), an external
URL/relative path (referenced, small file), or `asset:`; the editor embeds
picked files and `confirm()`s above `MEDIA_EMBED_BUDGET` (8MB, model.ts),
offering a URL instead (browser file-pickers give bytes not a path, so
"reference a local file" isn't possible — the URL field is the escape hatch).
render.ts: real `