diff --git a/CHANGELOG.md b/CHANGELOG.md index 190e0907..d90e39a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ pre-1.0. ## [Unreleased] +- **Live slide broadcast.** *Save broadcast copy…* in the Share menu exports a + standalone viewer file of the current deck. Hand that file out and present: + every open copy follows your current slide — transitions and morphs included + — over the existing relay, with a presenter laser and black-screen controls. + The channel carries only a slide number, never content, and the broadcast + room is derived from your signing key, so copies carry no credentials and + the shareable link has nothing secret in it. A deck with a hosting URL mints + a hosted client link instead, so any presenter's room can drive the same + hosted copy. The relay must be deployed with the control-frame changes for + enforcement. ## [1.0.19] — 2026-09-04 - **Bento Slides works on a phone.** Eight changes land together, because diff --git a/CLAUDE.md b/CLAUDE.md index 2021baf1..34677947 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -490,6 +490,26 @@ names provisional. **Share exports** (invite/viewonly/presentonly/template) pass a filename suffix and NEVER retain the FSA handle (`writeUpdatedFileAs` opts.keepHandle — retaining it made a later ⌘S overwrite the export with the full doc). +- **Live broadcast (v0.9.19)**: "Save broadcast copy…" (Share menu) embeds + `collab.broadcast = {room, relay}` — no tok, no keys, `on:false`, fresh + docId, plaintext even for encrypted decks — and the copy boots into a locked + present-follow viewer. The room is derived from the PRESENTER's signing key + (`broadcastRoom`: b64url(sha256(pub))[0:10], re-hashed until it doesn't + start with 'w' — a 'w' name would be mistaken for a signed collab room by + the relay); the connect tok is derived from the room name, so URLs carry no + tok. Signer resolution: ownerPriv → invite → writerPriv → device-local + `bento-broadcast-` key. The relay TOFUs the presenter's `?w=` per + non-`w` room and verifies nav/laser/black frames (`nav.${n}` / + `laser.${p}` / `laser.off` / `black.on` / `black.off` signature texts); + laser is ~30fps (33ms), RATE_BURST 400/10s. The speaker popup's Broadcast + link row + Copy live in an injected IIFE (bcastScript) — GOTCHAS: regexes + inside the template literal need DOUBLE-escaped backslashes (`/^https?:\/\//i` + → `/^https?:\\/\\//i`), bind click listeners once (`window.__bentoBcastBound` + + per-row `dataset.bound`), and restate `[hidden]{display:none}` for any + rule that sets `display:flex/inline-block` on the same element (a display + property overrides the UA's `[hidden]`). Hosted variant: `doc.meta.hostClient` + (About dialog, Document properties) + a live reader replica on the deck's + collab room; see docs/broadcast-design.md + docs/hosted-broadcast-design.md. - **Canvas slide nav (v1.0.2)**: with NOTHING selected (and not text/cell/path editing), arrow keys walk slides and a plain wheel over `.ed-scroll` walks slides (threshold 40px + 400ms cooldown so a trackpad swipe = one slide; skips diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3c143b54..eb6eb469 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3621,6 +3621,53 @@ payload 72KB → 79KB). Most of it is the finding messages, which are the product: a code with no explanation is not actionable. Anyone tempted to shrink this should shorten prose, not drop checks. +## 2026-08-08 — Live broadcast control channel + +**Decision.** The presenter of a deck can broadcast to copies of it over the +existing relay: a "Save broadcast copy…" Share-menu export embeds +`collab.broadcast = {room, relay}` and boots the copy into a locked +present-follow viewer; the presenter's speaker view arms a broadcast per show +(off by default) and sends signed control frames over a dedicated +BroadcastSocket. Frame types: `{ctl:'nav',n,g}` (presenter-visible slide +number, 1-based with interactive states excluded), `{ctl:'laser',p,g}` / +`{ctl:'laser',off:1,g}` (slide-fraction stroke points, hold-to-draw, ~30fps +client throttle) and `{ctl:'black',on:1|0,g}` (persisted as `lastBlack` and +replayed to late joiners, unlike laser which is transient). Signatures over +literal texts (`nav.${n}`, `laser.${p}`, `laser.off`, `black.on`/`black.off`) +with the presenter's key; the relay fans a signature-less copy (clients trust +its verification, like ops) and sends `{ctl:'presence',n}` viewer counts on +connect/close. The relay keeps the generic per-socket rate limiter (RATE_BURST +400/10s — enough for a full laser stroke with nav headroom) and the storage is +two small values (`lastNav`/`lastBlack`) replayed ahead of any live frame, so +a late joiner always lands on the newest slide. Relay changes are +backward-compatible: old copies ignore unknown ctl frames. Details: +docs/broadcast-design.md, server/sync-worker/src/worker.js, +slides/src/present.ts, slides/src/main.ts. + +## 2026-08-10 — Broadcast rooms derived from the presenter's signing key; hosted client + +**Decision.** One room system for ALL broadcasts: the room is derived from the +PRESENTER's signing key — the key this copy signs control frames with: the +owner key (owner deck), the per-copy invite key (a shared editor copy — each +invitee's room is unique to their copy), the shared writer key (legacy), or a +device-local broadcast key (no-collab decks). `room = b64url(sha256(signerPub)) +[0:10]`, re-hashed until it doesn't start with 'w' (a 'w' name would be +mistaken for a signed collab room by the relay); the relay connect token is +derived from the room name (`tok = b64url(sha256(room))[0:18]`). Presenter and +viewers compute the same values, so the shareable link is +`?room=` (no tok) and the copy embeds `broadcast:{room,relay}` +(no tok). The relay TOFUs the presenter's `?w=` signer key per non-`w` room +(like the tok) and verifies nav/laser/black against it; signed rooms keep the +owner-key commitment. The collab-socket reuse path is removed; earlier +`?b=` re-pointing and `?room=&tok=` URL formats were dropped during +development (no backward compatibility). The hosted variant: a broadcast copy +hosted once on the presenter's server (`doc.meta.hostClient`, set in the +About dialog's Document properties — no prompt), re-pointed at any presenter +via `?room=`, and joining the deck's collab room as a live reader replica so +content updates in real time. The URL carries only the nav capability; the +file carries the deck key (same trust as the read-only copy). Key rotation +breaks the pinned signer key until the deck is duplicated (new docId) — +accepted. Design: docs/broadcast-design.md, docs/hosted-broadcast-design.md. ## dash: a workbook holds two kinds of sheet — spreadsheets and datasets 2026-08-11. Settles a tension that was shipping as a visual lie. diff --git a/docs/broadcast-design.md b/docs/broadcast-design.md new file mode 100644 index 00000000..e772162b --- /dev/null +++ b/docs/broadcast-design.md @@ -0,0 +1,228 @@ +# Live slide broadcast — design + +Status: approved (2026-08-10). Companion to `collab-design.md` and +`relay-design.md`; the hosted viewer variant extends this in +`docs/hosted-broadcast-design.md`. Nothing touches the CRDT, the op log, the +room byte cap, or existing file formats. + +## What it is + +The presenter of a `.bento.html` deck broadcasts their **current slide** to +any number of viewers in near real time, over the existing Cloudflare Worker + +Durable Object relay. Viewers need no write access, no account, and no +collaboration keys — just the broadcast copy. + +**The broadcast copy is a standalone `.bento.html` built from the presenter's +deck** via the Share menu ("Save broadcast copy…"). It carries the deck +content plus the room connection credentials, boots directly into a locked +present-follow mode, and is driven live by the presenter's show: on slide +change, every connected copy follows — same slide, same transitions, same +morphs — because the copy runs the real renderer on its own copy of the +document. + +The nav channel stays deliberately narrow: it carries **only a slide number** +(plus laser/black control), never content — the relay must never hold a +document, and the copy already has one. + +## Room & key model + +One room system for ALL broadcasts. The broadcast room is **derived from the +presenter's signing key** — the key this copy signs control frames with: + +- owner key (`collab.ownerPriv`, an owner deck), +- per-copy invite key (`collab.invite`, a shared editor copy — each invitee's + room is unique to their copy), +- shared writer key (legacy rooms), +- or a device-local broadcast key (`bento-broadcast-` in localStorage, + no-collab decks). + +`room = b64url(sha256(signerPub))[0:10]`, re-hashed until the name does not +start with `w` (a `w` name would be mistaken for a signed collab room by the +relay). The relay connect token is derived from the room name: +`tok = b64url(sha256(room))[0:18]` — the presenter and every viewer compute +the same value, so **URLs and files carry no tok**: the shareable link is +`?room=` and the copy embeds `broadcast:{room,relay}`. + +The presenter always opens a **dedicated BroadcastSocket** on the derived room +(the collab-socket reuse path is removed). The relay pins the presenter's +signer key per room (trust-on-first-use, like the tok) and verifies +nav/laser/black frames against it; signed collab rooms keep the owner-key +commitment check. + +## Frame protocol + +### Presenter → relay + +```jsonc +{ "ctl": "nav", "n": 4, "g": "" } +{ "ctl": "laser", "p": "0.42,0.71", "g": "…" } // stroke point (slide fractions) +{ "ctl": "laser", "off": 1, "g": "…" } // stroke end +{ "ctl": "black", "on": 1, "g": "…" } // blackout on/off +``` + +- `n` — positive integer, the **presenter-visible slide number**: + `visibleIndex()` in present.ts (1-based, interactive states excluded) — the + same number the speaker view's counter shows. The copy maps `n` back onto + its own copy of the same document, so indices agree by construction. +- `p` — pointer position as slide-fraction `x,y`; `off` ends the stroke. +- Signature texts: `nav.${n}`, `laser.${p}`, `laser.off`, `black.on`, + `black.off` — literal text signed with the presenter's key via `signText()` + from `sync/online.ts`, same shape as `rev.${pub}`. +- Laser is throttled to ~30fps client-side (33ms); the relay's per-socket rate + budget (RATE_BURST 400/10s) covers a full stroke with nav headroom. +- **Rejections are silent** — fire-and-forget control frames, no `refused` + echo; the presenter's UI state is the feedback. + +### Relay handling (worker.js) + +For non-`w` broadcast rooms: trust-on-first-use pins the first `?w=` signer +key per room (validated `/^[A-Za-z0-9_-]{80,200}$/`), 403 on mismatch. +`controlKeyOk(meta)` verifies frames: signed rooms check the name commitment +(`'w'+sha256b64u(pubRaw) === name`), broadcast rooms check `meta.w === +pinned signerKey`. The owner's own socket is the only one whose key passes, +so member/viewer sockets cannot broadcast even with a valid sig under their +own key. + +- **Storage**: `lastNav` and `lastBlack` persist as single small values (not + in the op log, not in the byte accounting); they die with the room after + ~30 idle days. Laser is transient by design — a mid-stroke joiner misses it. +- **Fan-out**: `{ctl:'nav',n}` / `{ctl:'laser',…}` / `{ctl:'black',…}` with no + signature in the copy — clients trust the relay's verification, exactly like + fanned-out ops. Sender excluded. +- **Replay**: `replay()` sends `lastNav` (and `lastBlack`) as its **first** + step, before the snapshot/op reads — a live nav fanned out mid-replay can + only interleave at a later await, so "apply every nav as it arrives" is + race-free: the joiner always ends on the newest slide. +- **Presence**: on connect and on close, the relay fans + `{ctl:'presence', n: viewers}` where viewers = connected sockets whose + pinned key does not commit to the room's owner key (the presenter's own + socket is excluded; collab members count as viewers). +- **Rate limiting**: control frames ride the generic per-socket limiter + (`RATE_BURST` / `RATE_WINDOW_MS`) that runs on every message before parse — + no exemption. + +## Broadcast copy (Share menu export) + +**Where it lives**: the Share panel, beside "Read-only copy…": **"Save +broadcast copy…"**. One click serializes the current doc with the broadcast +fields embedded and downloads a **new file** (original untouched, rollback +free — same pattern as every other export). The file *is* the viewer; no +hosting, no site generation. + +**The mode marker is additive**: the copy embeds +`doc.broadcast = { room, relay }` — nothing in the format changes for +existing files, and old shells ignore the unknown field. The current shell +checks for it at boot: if present → **broadcast-viewer mode** (below), and +the editor never mounts. The copy sets `collab.on:false` explicitly (legacy +shells treat an absent `on` as "on"), gets a **fresh docId** (a derived +artifact, not an identity-keeping copy), and carries **no private keys, no +symmetric key, no sync state**. Encrypted decks export as plaintext (the copy +is a plaintext share by definition, same tradeoff as every export); the +owner's file stays encrypted. + +**Stale copy, accepted**: the copy is a build-time snapshot. If the owner +edits after building, an old copy can show an outdated deck and an +out-of-range `n` (the client clamps to the last slide). Rebuild after +material edits. + +## Presenter client + +- **Toggle**: a button in the speaker view's `.sv-ctrls` toolbar + (`navBtn('broadcast', ICONS.broadcast, t('Broadcast to audience'))`), **off + by default, every show** — presenting locally must never silently broadcast. + Active state + viewer count refresh through `updateSpeakerControls()`. +- **Arm flow** (`toggleBroadcast` in present.ts → helpers in `sync/online.ts`): + resolve the signing key (room derivation above) → open the dedicated + `BroadcastSocket` → send the **current** slide immediately (the opening + slide never fires `slidechanged`, so late joiners must get the starting + position from the stored `lastNav`). From then on the existing + `slidechanged` handler sends `sendNav(visibleIndex(toIdx))`. +- **Broadcast link row**: while armed, and only when `doc.meta.hostClient` + is set, the speaker popup shows a "Broadcast link" row with the hosted + viewer URL `?room=` + a Copy button; a "Set hosting URL" + button appears when the field is missing. `doc.meta.hostClient` lives in + the About dialog's Document properties (additive format field, inherited by + every collaborator's copy) — the broadcast popup never prompts for it. +- **Clipboard**: `navigator.clipboard.writeText` needs focus and user + activation in the document that calls it; the click is in the popup, so + `openSpeaker` appends one small inline script that fills a readonly input + from `postMessage` (`{bento:'broadcast', link|null}`) and copies in popup + context. The editor owns crypto/state; the popup owns clipboard. +- **Teardown**: toggle off, or show exit (`exit()`), closes the socket, + unhooks the flag, resets the button and the popup row. A broadcast never + outlives its show; `lastNav` persists on the relay, so a copy that + reconnects mid-show (or for the next show) lands on the right slide. + +## Broadcast client (the copy's runtime) + +Boot: `doc.broadcast` present → **broadcast-viewer mode** — the full +present overlay (real Reveal, morphs, fx, the entire renderer) on the embedded +document, with no editor, no autosave, no collab session, no Save path. + +- **Connect**: `new WebSocket(relay + '/d/' + room + '?tok=' + derivedTok)` + — no `?w=`: the unauthenticated-for-reads path. `since=0` replays the room + (ciphertext noise the copy ignores, never a crash). +- **Apply**: every `{ctl:'nav', n}` maps `n` onto the deck's own slide order + (the same 1-based, states-excluded numbering) and goes there via the same + goTo machinery the presenter uses, so transitions and morphs play normally. + The replayed `lastNav` lands a mid-show joiner on the current slide; before + the first frame: "Waiting for presenter". Out-of-range `n` clamps to the + last slide. +- **Status chip**: dark corner chip — "Connecting…" / "Waiting for + presenter" / "Live · N viewers" / "Broadcast ended". +- **Reconnect/backoff** copied from `OnlineTransport`: 800ms × 1.8, cap 30s, + reset on open; ping every 25s with pong check so a half-open socket + reconnects instead of hanging. Connection loss shows "Broadcast ended" but + keeps retrying — the presenter may re-present on the same room. +- **Ignore everything else** — collab ciphertext, unknown control frames. +- **Esc** exits to a minimal card (playerMode's exit pattern). + +## Hosted client + +A broadcast copy can be hosted ONCE on the presenter's server and re-pointed +at any presenter's room via `?room=` — so a replacement presenter takes +over without re-exporting files. The hosted copy additionally joins the deck's +collab room as a live reader replica, so slide content updates in real time +as the deck is edited. Full design: `docs/hosted-broadcast-design.md`. + +## Auth model & threat model + +- **Read**: the broadcast copy *is* the capability — sharing the file shares + the feed, exactly as "Save read-only copy…" shares the document today. The + room name is a hash of the presenter's public key (256-bit entropy — + unguessable); possession of the URL `?room=` is possession of the + room (the tok is derived from it). +- **Write**: impossible. Control frames require the presenter's signature — + verified against the per-room pinned key; op batches require the writer key + the copy doesn't have. +- **Spoofing**: presenter-signed per frame, verified inline by the relay. A + hostile copy, a link-scraper, and the relay itself can all fabricate + frames — same trust position as every frame in this system, and the worst + case is a wrong slide on one screen. +- **Leakage**: the relay learns the deck exists (room activity), the slide + number, the viewer count — the metadata it already sees for collab rooms; + never a title, never content. The copy itself is a plaintext deck by + construction: the presenter chose to share it. +- **Key rotation** changes the signer key → the pinned broadcast room refuses + the new key until the deck is duplicated as a new deck (new docId, new + room) — accepted limitation. + +## Verification + +- **Relay**: `npx wrangler dev --port 8787` + `server/sync-worker/nav-check.mjs` (Node's + built-in WebSocket, no deps): mints a key, connects presenter + viewer + sockets, asserts unsigned/forged/member-key frames dropped, valid frames + fanned out, `lastNav` replayed ahead of any live frame, presence rises on + connect and falls on close, rate limiter still applies. The relay must be + `wrangler deploy`d for enforcement — same operational rule as every relay + change. +- **Client**: `tsc -b` + `npm run build:single`; manual two-tab session: + `scripts/build-broadcast-example.mjs` builds the owner/copy/hosted fixtures + into `working/broadcast-demo/` → open the copy (boots into present-follow, + "Waiting for presenter") → present + arm → the copy follows slide-for-slide, + transitions included → a second copy opened mid-show lands on the current + slide → disarm → the count drops and the feed goes quiet (copies stay on + the last slide, re-follow on re-arm) → exit ends the broadcast → a stale + copy clamps instead of crashing. +- New UI strings go into **all** i18n catalogs (AGENTS.md hard rule 6). +- No `crdt.ts` changes → `scripts/test-sync.ts` unaffected. diff --git a/docs/hosted-broadcast-design.md b/docs/hosted-broadcast-design.md new file mode 100644 index 00000000..8daaf530 --- /dev/null +++ b/docs/hosted-broadcast-design.md @@ -0,0 +1,133 @@ +# Hosted broadcast client — design + +Status: approved (2026-08-09), revised (2026-08-10: derived rooms, no tok in +URLs/files). Follows docs/broadcast-design.md; extends the broadcast feature +with a hosted, driver-switchable client. + +## Problem + +The broadcast copy is a file: user1 exports it, hands it to the client, presents. +When user5 must replace user1, the client needs a new copy (or a manual re-point). +For a real live broadcast the client should be hosted ONCE on the presenter's +server, and the URL should select which room drives it — so any presenter +(user5 replacing user1) can take over without re-exporting files. + +## Scenario + +- user1..user4 each export a broadcast client and present. +- user5 takes over for user1: user5's deck mints a link to the SAME hosted + client with user5's room; the client opens it and follows user5. + +## Decisions + +1. **Same deck, new driver.** The hosted client is deck X's broadcast copy, + served from a URL. The room param only switches WHO drives it. Deck content + is whatever the copy carries (see decision 4 for live updates). +2. **Derived rooms, no secrets in URLs.** The broadcast room is derived from + the PRESENTER's signing key — the key this copy signs control frames with: + the owner key (owner deck), the per-copy invite key (a shared editor copy — + each invitee's room is unique to their copy), the shared writer key + (legacy), or a device-local broadcast key (no-collab decks): + `room = b64url(sha256(signerPub))[0:10]`, re-hashed until it doesn't start + with 'w' (a 'w' name would be mistaken for a signed collab room by the + relay). The relay connect token is derived from the room name: + `tok = b64url(sha256(room))[0:18]` — the presenter and every viewer compute + the same value, so the shareable URL carries no tok and the broadcast copy + embeds no tok: `?room=`. One room system for ALL + broadcasts (hosted or not); the copy embeds `broadcast:{room,relay}` at + export. +3. **Hosting URL lives in the doc.** `doc.meta.hostClient` (additive format + field, same mechanism as author/company) is set in the About dialog's + Document properties. Every collaborator's copy of the deck inherits it, so + user5's deck mints hosted links automatically. The broadcast popup shows a + "Broadcast link" row with the hosted viewer URL and Copy — only while the + presenter is actively broadcasting, and only when the deck carries a + hosting URL. No "Set hosting URL" prompt: the field lives in the About + dialog. +4. **Live doc sync to the hosted client.** The hosted copy joins the deck's + collab room as a live reader replica, so slide content updates in real time + as the deck is edited. Re-host once; the copy converges as a fork on first + join (stampInto + merge machinery, already verified under partition). + +## Architecture + +### Anatomy + +The hosted copy is a broadcast copy (deck snapshot + `broadcast:{room,relay}`) +that ALSO carries the collab read cap (`collab.role:'reader'` + symmetric `key`) +and sync state (`session.stampInto`). This is exactly the existing "read-only +live viewer" export (v0.9.18) with the broadcast boot path on top. + +### Boot + +`broadcastMode` (present-follow overlay) plus a background reader SyncSession +joining the deck's collab room. Remote ops apply via the session's direct +state.apply+emit path (the reader-mode mechanism — no editor rewrites). The +current slide re-renders on remote doc changes. + +### Sockets + +- **Content**: the deck's collab room (reader replica). +- **Navigation**: the presenter's broadcast room, selected by `?room=`. +- The presenter always opens a dedicated BroadcastSocket on the derived room + (the collab-socket reuse path is removed). The relay pins the presenter's + signer key per room (trust-on-first-use, like the tok) and verifies + nav/laser/black frames against it. + +### Link minting + +- Export: "Save broadcast copy…" embeds `broadcast:{room,relay}` (room derived + from the presenter's signing key, relay = the deck's collab relay). +- Popup: one "Broadcast link" row showing the hosted viewer URL + `?room=` + Copy, shown only while broadcasting and + only when `doc.meta.hostClient` is set. +- user5 arms → their deck mints the same shape with their room → client opens + it → same copy, new driver. + +### URL parsing + +The hosted copy accepts `?room=` overriding the embedded broadcast room +(same relay). Malformed params fall back to the embedded room. The old +`?room=&tok=` and `?b=` formats are dropped (no backward compatibility). + +## Security + +- The URL carries only the nav capability — the room name alone, and the tok + is derived from it, so possession of the URL is possession of the room. +- The FILE carries the deck's symmetric key — same trust as the read-only copy + export; the server operator can decrypt the deck. It is the presenter's own + server. +- Relay: broadcast rooms (non-`w`) TOFU the presenter's `?w=` signer key per + room; nav/laser/black verify against the pinned key. Signed rooms keep the + owner-key commitment check. A rotated owner key changes the signer key → + broadcast refused until the deck is duplicated as a new deck (new docId, + new room) — accepted limitation. + +## Scope of changes + +- `slides/src/main.ts` — broadcastMode: background reader SyncSession, slide + re-render on remote doc changes, `?room=` parsing, derived tok. +- `slides/src/editor/editor.ts` — broadcast copy embeds `{room,relay}` (no tok). +- `slides/src/sync/online.ts` — `broadcastRoom(signerPub)` / + `broadcastTok(room)` derivation helpers, hosted-link minting (reuses + resolveBroadcastCreds). +- `slides/src/present.ts` — popup broadcast link row (no set-host flow). +- `server/sync-worker/src/worker.js` — TOFU signerKey for non-`w` rooms + + control-frame verification against it. +- `docs/DECISIONS.md` — entry. + +## Known limits + +- Switching drivers = opening the new link (no server-side redirect — the + price of zero server code). A tiny Worker could later map short ids or + redirect rooms server-side. +- Live content sync requires the deck to be a live deck (collab on, shared). + A non-live deck degrades to today's snapshot broadcast. +- The hosted copy is one viewer in the count. +- Key rotation breaks the broadcast room's pinned signer key (see Security). + +## Non-goals + +- No server-side deck storage (no generic shell, no upload endpoint). +- No server-side room mapping / URL shortener. +- No backward compatibility with the `?room=&tok=` / `?b=` URL formats. diff --git a/scripts/build-broadcast-example.mjs b/scripts/build-broadcast-example.mjs new file mode 100644 index 00000000..6ec264ce --- /dev/null +++ b/scripts/build-broadcast-example.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// Build the live-broadcast example fixtures: +// 1. Owner deck (owner.bento.html) — real v2 collab room, owner creds in-file. +// 2. Broadcast copy (copy.bento.html) — snapshot follow mode (no collab creds). +// 3. Hosted copy (hosted.bento.html) — broadcast creds + live reader replica. +// +// node scripts/build-broadcast-example.mjs [--relay wss://host] +// +// Output: working/broadcast-demo/ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { webcrypto as crypto } from 'node:crypto' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const shell = readFileSync(join(root, 'slides/dist-single/Bento_Slides.bento.html'), 'utf8') + +const DEFAULT_RELAY = 'wss://sync.bento.page' +const relay = parseRelay(process.argv.slice(2)) + +// ═══════════════════════════════════════════════════════════════════════ +// base64url (byte-identical to slides/src/sync/online.ts) +// ═══════════════════════════════════════════════════════════════════════ +const b64u = { + enc(bytes) { + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + }, + dec(s) { + const b = atob(s.replace(/-/g, '+').replace(/_/g, '/')) + const out = new Uint8Array(b.length) + for (let i = 0; i < b.length; i++) out[i] = b.charCodeAt(i) + return out + }, +} + +function parseRelay(args) { + const i = args.indexOf('--relay') + if (i !== -1 && args[i + 1]) return args[i + 1].replace(/\/+$/, '') + return DEFAULT_RELAY +} + +function uuid() { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')) + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` +} + +async function mintCollab() { + const kp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) + const pub = b64u.enc(new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey))) + const priv = b64u.enc(new Uint8Array(await crypto.subtle.exportKey('pkcs8', kp.privateKey))) + const commit = new Uint8Array(await crypto.subtle.digest('SHA-256', b64u.dec(pub))) + const roomName = 'w' + b64u.enc(commit) + const room = `${relay}/d/${roomName}` + const keyBytes = new Uint8Array(32) + crypto.getRandomValues(keyBytes) + const key = b64u.enc(keyBytes) + const tokDigest = new Uint8Array(await crypto.subtle.digest('SHA-256', keyBytes)) + const tok = b64u.enc(tokDigest.slice(0, 18)) + return { room, roomName, key, tok, owner: pub, ownerPriv: priv } +} + +// Broadcast room derived from the presenter's signing key (byte-identical to +// slides/src/sync/online.ts broadcastRoom): 10-char b64url of sha256(seed), +// re-hashed until it doesn't start with 'w' (a 'w' name would be mistaken for +// a signed collab room by the relay). +async function broadcastRoom(seed) { + let name = '' + do { + const d = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed))) + name = b64u.enc(d).slice(0, 10) + seed = name + } while (name[0] === 'w') + return name +} + +// ═══════════════════════════════════════════════════════════════════════ +// tiny SVG asset (geometric bento mark), embedded as a data URI +// ═══════════════════════════════════════════════════════════════════════ +const svgMarkup = + `` + + `` + + `` + + `` + + `` + + `` +const bentoImage = 'data:image/svg+xml;base64,' + btoa(svgMarkup) + +// ═══════════════════════════════════════════════════════════════════════ +// deck builders +// ═══════════════════════════════════════════════════════════════════════ +const INK = '#0D1B2E' +const PAPER = '#F2F0EA' +const PEACH = '#FF9E8A' +const STEEL = '#5E7699' +const BODY = "'Instrument Sans', system-ui, sans-serif" +const DISPLAY = "'Fraunces', Georgia, serif" + +let uid = 0 +const id = (p) => `${p}-${(++uid).toString(36)}` + +const text = (o) => ({ + id: o.id ?? id('t'), + type: 'text', + x: o.x, + y: o.y, + w: o.w, + h: o.h, + rotation: o.rotation ?? 0, + opacity: o.opacity ?? 1, + html: o.html, + fontSize: o.fontSize ?? 24, + fontFamily: o.fontFamily ?? BODY, + fontWeight: o.fontWeight ?? 400, + color: o.color ?? INK, + align: o.align ?? 'left', + valign: o.valign ?? 'top', + lineHeight: o.lineHeight ?? 1.3, + ...(o.fx ? { fx: o.fx } : {}), +}) + +const shape = (kind, o) => ({ + id: o.id ?? id('s'), + type: 'shape', + shape: kind, + x: o.x, + y: o.y, + w: o.w, + h: o.h, + rotation: o.rotation ?? 0, + opacity: o.opacity ?? 1, + fill: o.fill ?? '#000', + stroke: o.stroke ?? 'none', + strokeWidth: o.strokeWidth ?? 0, + radius: o.radius ?? 0, + ...(o.fx ? { fx: o.fx } : {}), +}) + +const chart = (o) => ({ + id: o.id ?? id('c'), + type: 'chart', + x: o.x, + y: o.y, + w: o.w, + h: o.h, + rotation: 0, + opacity: 1, + preset: o.preset ?? 'bar', + option: o.option, + ...(o.fx ? { fx: o.fx } : {}), +}) + +const slide = (o) => ({ + id: o.id ?? id('sl'), + background: o.background, + transition: o.transition ?? 'fade', + notes: o.notes ?? '', + elements: o.elements, +}) + +function buildDoc(docId, collab) { + const sTitle = slide({ + id: 'bc-title', + background: INK, + transition: 'none', + notes: 'Broadcast demo cover. A simple title slide on the ink background.', + elements: [ + text({ + x: 96, y: 240, w: 1088, h: 120, + html: 'Bento Broadcast Demo', + fontSize: 84, fontWeight: 900, fontFamily: DISPLAY, color: PAPER, lineHeight: 1.05, + }), + text({ + x: 96, y: 380, w: 900, h: 40, + html: 'A tiny deck that follows the presenter live.', + fontSize: 24, color: 'rgba(242,240,234,0.75)', + }), + shape('rect', { x: 96, y: 430, w: 160, h: 6, fill: PEACH }), + ], + }) + + const sBullets = slide({ + id: 'bc-bullets', + background: PAPER, + transition: 'fade', + notes: 'Bullet slide showing the broadcast value proposition.', + elements: [ + text({ x: 96, y: 84, w: 900, h: 60, html: 'What broadcast does', fontSize: 52, fontWeight: 900, fontFamily: DISPLAY, color: INK }), + shape('rect', { x: 96, y: 150, w: 1088, h: 2, fill: 'rgba(13,27,46,0.15)' }), + ...[ + ['No accounts', 'Viewers open a file, not a login page.'], + ['Same renderer', 'The copy runs the real Bento presenter — morphs included.'], + ['Number-only nav', 'The relay sees only a slide index, never content.'], + ].flatMap(([head, body], i) => [ + shape('ellipse', { x: 116, y: 210 + i * 130, w: 20, h: 20, fill: PEACH }), + text({ x: 156, y: 196 + i * 130, w: 800, h: 44, html: `${head}`, fontSize: 28, fontWeight: 800, color: INK }), + text({ x: 156, y: 244 + i * 130, w: 800, h: 36, html: body, fontSize: 20, color: 'rgba(13,27,46,0.72)' }), + ]), + ], + }) + + const sImage = slide({ + id: 'bc-image', + background: PAPER, + transition: 'fade', + notes: 'Image slide: a small inline SVG data URI used as a self-contained asset.', + elements: [ + text({ x: 96, y: 84, w: 900, h: 60, html: 'Self-contained assets', fontSize: 52, fontWeight: 900, fontFamily: DISPLAY, color: INK }), + shape('rect', { x: 96, y: 150, w: 1088, h: 2, fill: 'rgba(13,27,46,0.15)' }), + text({ x: 96, y: 200, w: 560, h: 200, html: 'This image is embedded as a data URI in the doc JSON — no external host, no broken link when the copy travels.', fontSize: 22, color: 'rgba(13,27,46,0.72)', lineHeight: 1.5 }), + { id: id('im'), type: 'image', x: 720, y: 180, w: 480, h: 360, rotation: 0, opacity: 1, src: bentoImage, fit: 'contain', radius: 16 }, + ], + }) + + const sMorphA = slide({ + id: 'bc-morph-a', + background: PAPER, + transition: 'fade', + notes: 'Morph beat part 1: the amber block and title are about to rearrange.', + elements: [ + text({ id: 'bc-m-title', x: 96, y: 84, w: 900, h: 60, html: 'Morph transition', fontSize: 52, fontWeight: 900, fontFamily: DISPLAY, color: INK }), + shape('rect', { id: 'bc-m-bar', x: 96, y: 160, w: 320, h: 24, fill: PEACH }), + text({ x: 96, y: 230, w: 600, h: 160, html: 'The next slide shares these element ids.
Bento tweens position, size and color automatically.', fontSize: 24, color: 'rgba(13,27,46,0.72)', lineHeight: 1.5 }), + shape('rect', { id: 'bc-m-box', x: 96, y: 430, w: 200, h: 200, fill: STEEL, radius: 12 }), + ], + }) + + const sMorphB = slide({ + id: 'bc-morph-b', + background: INK, + transition: 'morph', + notes: 'Morph beat part 2: same ids, new frames. The amber bar becomes a column, the box slides right and changes colour.', + elements: [ + text({ id: 'bc-m-title', x: 96, y: 60, w: 500, h: 44, html: 'Morph transition', fontSize: 32, fontWeight: 900, fontFamily: DISPLAY, color: 'rgba(242,240,234,0.7)' }), + shape('rect', { id: 'bc-m-bar', x: 96, y: 120, w: 16, h: 520, fill: PEACH }), + text({ x: 150, y: 120, w: 500, h: 160, html: 'Same ids.
New frames.
Instant motion.', fontSize: 48, fontWeight: 800, color: PAPER, lineHeight: 1.2 }), + shape('rect', { id: 'bc-m-box', x: 780, y: 260, w: 360, h: 360, fill: PEACH, radius: 180 }), + ], + }) + + const sChart = slide({ + id: 'bc-chart', + background: PAPER, + transition: 'fade', + notes: 'Chart slide: a simple bar chart using the ECharts-shaped option JSON that charts-lite understands.', + elements: [ + text({ x: 96, y: 84, w: 900, h: 60, html: 'Charts work too', fontSize: 52, fontWeight: 900, fontFamily: DISPLAY, color: INK }), + shape('rect', { x: 96, y: 150, w: 1088, h: 2, fill: 'rgba(13,27,46,0.15)' }), + chart({ + x: 96, y: 200, w: 1088, h: 420, + preset: 'bar', + option: { + grid: { left: 48, right: 16, top: 24, bottom: 48 }, + xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] }, + yAxis: { type: 'value' }, + color: [PEACH, STEEL], + tooltip: { trigger: 'axis' }, + legend: { bottom: 0, textStyle: { color: '#6B7280' } }, + series: [ + { type: 'bar', name: 'Views', data: [42, 68, 54, 86, 73], itemStyle: { borderRadius: [6, 6, 0, 0] } }, + { type: 'bar', name: 'Edits', data: [28, 35, 42, 51, 64], itemStyle: { borderRadius: [6, 6, 0, 0] } }, + ], + }, + fx: { enter: 'fade-up' }, + }), + ], + }) + + return { + format: 'bento/slides', + version: 1, + docId, + title: 'Bento Broadcast Demo', + size: { width: 1280, height: 720 }, + theme: { background: PAPER, color: INK, accent: PEACH, fontFamily: BODY }, + modified: new Date().toISOString(), + slides: [sTitle, sBullets, sImage, sMorphA, sMorphB, sChart], + collab, + } +} + +function spliceDoc(shell, doc) { + const json = JSON.stringify(doc).replace(/[\s\S]*?<\/script>/ + const out = shell.replace(blockRe, `