# 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 `