Getting it into your agent
One page per mod, every tool's command on it. A separate URL per tool would split the same page into five that compete with each other.
npx agentmods add instructions/nyblnet/bento/claude-mdgit clone --depth 1 https://github.com/nyblnet/bentoWrote this? Show the measurements
A badge with what this costs and how it scanned, read live from this page, so it follows the numbers instead of freezing them. Markdown for a README, HTML for a documentation site or a project page.
[](https://agentmods.dev/instructions/nyblnet/bento/claude-md)<a href="https://agentmods.dev/instructions/nyblnet/bento/claude-md"><img src="https://agentmods.dev/badge/instructions/nyblnet/bento/claude-md.svg" alt="Measured on agentmods" height="20"></a>What it costs to keep this loaded
Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.
| Model | Per session | Once invoked |
|---|---|---|
| Fable 5 | $0.12831 | $0.12831 |
| Opus 5 | $0.06416 | $0.06416 |
| Sonnet 5 | $0.02566 | $0.02566 |
| Haiku 4.5 | $0.01283 | $0.01283 |
Grade A, and why
bento CLAUDE.md scanned grade A with 0 findings against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured 4d ago.
A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.
Nothing flagged
None of the 26 patterns this scan looks for appear in this file: no shell pipes, no recursive deletes, no credential paths, no hidden text, no instruction-override or anti-refusal phrasing, no agent-config snooping. That is not a guarantee, it is the absence of the things that are checkable.
How it starts
The opening of the file, as written. The whole thing — 685 lines — stays where its author put it; the contents beside it link to each section on GitHub.
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); Bento Spaces
(Notion/notes-like) and Bento Dash (data/spreadsheet) are starting —
names provisional.
Multi-app coordination (read first when working beyond slides/)
docs/PLATFORM.md— invariants EVERY Bento app must honor (splice contract, docId, format additivity, collab, signed updates). Breaking these bricks shipped files.docs/PARALLEL-WORK.md— ownership zones + branch/merge discipline for the many parallel agents/tools working on this repo. Kernel-zone changes are serialized, never parallelized.docs/DECISIONS.md— append-only decision log. Read before non-trivial work; append when you settle something other agents could contradict.AGENTS.md— the tool-agnostic contract (Codex/Antigravity read this).
Architecture (slides/)
src/model.ts— thebento/slidesJSON 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) — only the title arrives by 'fade', so nothing interrupts that thread. The stats beat used to fade too, on the belief that entrance staggers and count-ups needed a non-morph arrival; that has been FALSE since #197 (runMorph gives every unpartnered element its fx.enter, and runMorphArrivalCountUps counts up anything not carried from the previous slide), and the fade was costing the deck its best tile moment — the four scattered tiles collapsing into the row of chips. Verified by measurement, not belief: on that morph arrival all four tiles travel, 13 elements stagger in, and the count-up runs 0→100%. 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 fromfill(notstroke— 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-docdata block, re-serialize. JSON is<-escaped (\u003c) so it can never contain</script>. File System Access API first, download fallback.src/preview.ts+ kernelregisterPreview— static first-page preview for file-manager thumbnails. Thumbnailers (iOS Files, macOS QuickLook/Finder, bento/home) render HTML with JS OFF, so every deck used to thumbnail as the same boot splash. Every save now writes a still render of page one into the shell as a plain[data-bento-preview]element, followed IMMEDIATELY by a parser-blocking<script data-bento-preview>that deletes both. The thumbnailer keeps the preview (it runs no script); the reader never sees it (the remover executes before the browser paints — measured: at removalreadyStateisloadingandgetEntriesByType('paint')is EMPTY). NOT<noscript>— that was v1 and it does nothing on iOS, whose thumbnailer runs no script AND does not render<noscript>(probe: red default / green from inline script / blue in noscript renders RED). Anything between host and remover is markup a browser could paint, so the gate asserts the ordering. A QLThumbnailProvider extension does NOT work — registers fine, never launches, because iOS ownspublic.html; nor doesNSURLThumbnailDictionaryKey, silently dropped for local files. ReusesrenderSlidewithsvgAsImage:true(svg → one<img>, media → poster/icon still) +hidePlaceholders;staticize()strips active elements, runtime data-attrs andon*, and INLINES the few.bento-*rules from styles.css (the runtime CSS ships deflated and is never inflated in this mode). Fits an unknown viewport withtransform:scale(calc(min(100vw,<aspect>vh)/<w>px))—<svg><foreignObject>was tried and is MANGLED by QuickLook's WebKit; verify withqlmanage -t, not Chrome alone (Chrome 150's--blink-settings=scriptEnabled=falsekills--screenshot; use CDPEmulation.setScriptExecutionDisabled). GOTCHAS: never stripidfrom the render — svg gradients/markers paint through document-globalurl(#…); the removal of an existing preview is UNCONDITIONAL (replace-never-append:capturePristinealready holds the last save's copy, and a newly-encrypted deck must LOSE its preview). Encrypted decks NEVER get a preview (previewAllowed= no password flag AND body is not abento/encenvelope) — a plaintext title slide beside the ciphertext is the leak the password exists to prevent.PREVIEW_BUDGET(64KB, model.ts) tiers the output: full → images dropped for tinted boxes → title card. Guards:scripts/test-preview.ts+ shell-gate's preview-carrying-shell invariant. Full rationale in docs/DECISIONS.md.src/autosave.ts(v0.9.8) — auto-save + local version history, IndexedDB (bento-autosave, two stores:recoverysingle-latest-per-docId,versionscapped timeline). Editor debounces (2.5s) ondocevents: 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 bootcheckRecoverycompares the latest snapshot'sdocContentKey(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 carrydata-el-id(editing) anddata-flip-id(morph). Morph key (v1.0.7):data-flip-id = el.morphId || el.id.idstays the stable identity (selection, connector/comment anchors, CRDT node key); the DEFAULT morph key is stillid(the duplicate-a-slide idiom, old files have nomorphId), but an optionalmorphIdre-targets the pairing WITHOUT mutatingid— so two independently-created elements on different slides can morph. This one line is the whole engine change; everything downstream readsdata-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 optionaldoc.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).renderSlideauto-fillsRenderOpts.fieldsviafieldContext(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, butcanvas.startTextEditswaps the token BACK to rawel.htmlwhile 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 documentpastelistener 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 dropstateOf. 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 withtransition:'morph'morph matcheddata-flip-idelements 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 carryfx(enter stagger — equalorder= simultaneous, countUp, ken-burns (fx.kendir drift/out/in + zoom %/secs; out/in are one-shot settles per slide entry),loopdash-march/motion-path),link(click → slide id) andgroup; slides can sethover:'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 (localStoragebento-reduce-motion'on'/'off', default = OSprefers-reduced-motion), never in the doc — mirrors how locale is viewer-scoped. Toggle withMor the ⏸ speaker-view button (setReduceMotion/toggleReduceMotion). When on, the fx call sites inslidechanged/init are gated OFF (no runMorph/runEnterFx/ runAmbientFx/restartSvgAnimations — elements render at their final frame, count-ups show final values) and the.reduce-motionoverlay 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 sharededitor/bezier.tscore (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 oldanchorsToPath) 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 CasteljausplitSegment(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.loopmotion-path takesease(per-lap tempo, panel dropdown) andspeeds[](per-anchor multipliers, one per anchor — scroll a point in the path editor to set it; badge shows non-1 values). anim.tssamplePath(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.speedsstays 1:1 with the waypoints through every insert/remove/split becauseserializeBezieremits exactly one on-curve point per node (so anim.tsonCurvePointsrecovers 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'writerPrivstripped — "Save read-only copy…" (shown only when sharing is on); boots the editor in a locked state (store.readOnlyno-ops commit; remote ops apply via session's direct state.apply+emit, NOT commit, so live updates still land;editor.enterReaderModehides the insert group + Moveable handles, shows a pulsing banner; canvas text/cell edits early-return). (3) Writer (default). ENFORCEMENT is cryptographic, not honour-system:collabgains an ECDSA P-256 writer keypair (writerPubin every copy,writerPrivonly in writers) SEPARATE from the symmetrickey(read cap).mintCollabis 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 arer+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 bewrangler deployd 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 abento/encenvelope (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 withscripts/sign-release.mjs. Docs carry a stabledocId(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) bakesoption.colorfromdoc.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.yAxismay be an ARRAY of two value axes; a series picks one viayAxisIndex(0/1). renderCartesian computes a range per axis, shares gridline rows (2nd axis labels on the right, its own nice scale viafixedTicks), and honours per-axismin/max+axisLabel.formatter('{value}%'). Visual text styling: charts-lite honourslegend.textStyle.fontSize/fontWeight, legend marker size/gap and numerictop/bottom, x/yaxisLabel.fontSize/fontWeight, axis and split-line widths, and line-seriessymbolSize. Legend measurement treats CJK characters as full-width so localized series names do not overlap. 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.syncLinkedChartpushes the table's labels+numeric columns into the chart's option IN PLACE (data only; styling/axes preserved), and editorsyncLinkedChartsre-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:strokeStylesolid/dashed/dotted (legacystrokeDashstill honoured); line shapes havelineStart/lineEndtips (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 optionalshadow{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):
tableelement — a real HTML<table>(table-layout fixed) rendered by the shared render.ts (renderTableHtml), identical in editor/thumb/present/print. Model:columnsfractional weights,rowsof{cells:[{html,align?,color?,bg?,bold?}]},headerbool,styleobject (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-handleoverlays (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 collabrowsis a whole-value LWW register (concurrent different-cell edits are last-writer-wins — documented limitation). - Media (v0.9.16):
mediaelement (kindvideo|audio) mirrors image. HYBRID storage —srcis a data: URI (embedded, self-contained), an external URL/relative path (referenced, small file), orasset:; the editor embeds picked files andconfirm()s aboveMEDIA_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<video>/<audio>on canvas+present, a cheap poster/icon still in thumbnails (svgAsImage). Media ispointer-events:noneon the canvas (gated by theliveMediaRenderOpt, present-only) so its native controls don't swallow Selecto selection. Autoplay is NEVER set at render time (would fire on the canvas and in every thumbnail) — present.tsstartMediaIn/pauseMediaInplay flagged clips (data-autoplay="1") on slide-enter and pause on exit/teardown; browsers needmutedfor video autoplay sodefaultMediamutes video. Panel 'Source & playback' (buildMediaProps): embedded/linked status+size, Choose/Replace file, URL, controls/autoplay/loop/muted, and (video) fit/corner/poster. - Diagram philosophy: complex diagrams are ordinary Bento elements (rects, texts,
pathshapes) with groups — interactivity = linked state slides + morph (filters, era sequences), hover = focus-group, motion = fx.loop. Opaquesvgelements are the fallback for geography/static artwork only. - Interactive states:
Slide.stateOfmarks a slide as a hidden variant of another slide — skipped by linear navigation (← returns to parent, → continues past it), reached by elementlinks, morphing when ids are shared. Authoring: select an element → "+ New state linked from this element" in the Presenting panel. States live adjacent to their parent and render nested in the sidebar. - Layouts:
doc.layouts(Slide-shaped templates) + built-ins in model.ts. Instantiating KEEPS element ids — slides from the same layout share ids, so their chrome morphs across transitions (and re-apply-by-lineage stays possible). Textplaceholderprompts render dimmed in the editor and are hidden in present/print (hidePlaceholdersRenderOpt). Picker on the New-slide button AND the insert-gaps; "Save slide as layout" + "Apply layout" in the slide panel. Apply (model.applyLayout) matches donors by id, then byrole(title/subtitle/body/kicker — Role select in the element panel); layout supplies frame+typography, content rides along; layout-owned leftovers are dropped UNLESS they are text someone wrote; user extras stay. - Comments:
Slide.commentsthreads (author/at/text/replies/resolved) — saved in the file, editor-only (canvas markers + thread popover via editor/comments.ts; never in present/print). Anchors: element id, POINT (x/y slide coords — "📍 Comment at a point" arms a one-shot crosshair click, Esc cancels), or the slide. Author name in localStorage 'bento-author'; unresolved threads badge the sidebar thumb. ONE entry point: the topbar 💬 tool (C) — armed click on an element anchors there, on empty canvas anchors the point; OFF the slide (grey canvas) anchors the WHOLE SLIDE; near-full-slide backdrops never capture (a comment on scenery means the spot). While armed, hover previews the pending anchor (amber outline on elements, pin+coords elsewhere); fresh markers pulse.window.bento.comments()returns the flat typed-anchor list — the entry point for AI agents processing flagged issues in a deck. - Hover content is in-slide, not states:
showOnHoversets + slidehover:'reveal'(with a default set) swap content on pointer-over. Editor previews one set at a time. Rule of thumb: click → state slide; hover → reveal set. - Speaker view (presenter view): OUR OWN popup (present.ts openSpeaker) —
a PowerPoint-style presenter surface: elapsed timer (click resets) + clock,
current + next slide (renderSlide/svSlide), notes, a nav bar (first/prev/
next/last + live counter), a black-screen toggle (
.bento-blackoutover the audience overlay), a clickable thumbnail rail (all non-state slides, current highlighted + scrolled into view), and a lazy all-slides grid overlay. The popup has its OWN keydown handler (its keys fire in its own document) so the presenter drives the whole show from it: ←/→/space/PageUp-Dn, Home/End, B (black), G (grid), L (laser pointer), Esc (close grid). The laser is local presenter-session state, rendered through a pointer-transparent layer, and never enters the document or collaboration model.Lis handled in both audience and speaker documents because popup key events do not cross windows.updateSpeakerControls()refreshes highlight/counter/button-state cheaply on every slidechange; the expensive current/next re-render only inupdateSpeaker. Reveal's notes plugin is NOT used (it reloads the URL in an iframe → boots a whole second editor). Two-window lifecycle (src/screens.ts): the speaker window OUTLIVES a present session. The editor opens it (its own user gesture) via the FAB beside the present buttons, oreditor.openSpeakerView(); present mode ADOPTS the existing window (speakerWindow()), so "open the notes" and "go fullscreen" are two SEPARATE gestures and neither steals the other's activation. That is the whole fix for the macOS bug where a popup opened after fullscreen got trapped in the slides' exclusive fullscreen Space on the wrong monitor — open it FIRST, drag it to the second screen, then present (the Slide-panel notes hint says this, and warns on macOS viaisMacOS()). Present closes a window it opened itself on exit; an ADOPTED (editor-owned) window is left open and reset to an idle placeholder (speakerIdleBody). A 1s poll (watchSpeaker) clears the ref + firesonSpeakerWindowChangewhen the popup is closed manually.deckReadygatesdeck.getIndices()reads (the window can open pre-init). Window Management API was REMOVED (v1.0.2) — no more permission prompt; the window opens on the current display and the presenter drags it over.openingSpeakerstill guardsonFsChangeagainst the fullscreen bounce ending the show. - Animation robustness: slide exit kills tweens AND restores model frames; a 2.8s wall-clock settle guarantee lands entrances on starved render loops; never put entrance tweens on motion-path elements (transform conflict).
- Other format/runtime features:
doc.fonts[](@font-face from assets at boot); PDF export via print CSS (@pagesized 1600×900, states excluded); state "Sync from parent" (id-lineage merge — generators must emit deterministic element ids for it and for cross-state morphs); slide deletion cascades states and clears inbound links after confirm;[/]collapse the side panels. - Panel accordion: every
.ed-sectionheader is retrofitted into a collapsible group after each rebuild (panels.applyAccordion) — open state persisted per title in localStorage; Presenting/Interactivity/Layout default closed. Add new panel content under a section header and the accordion picks it up automatically. src/sync/— bento-sync live collaboration (design: docs/collab-design.md).crdt.tsis the engine (pure data, runs in node too): element node identity is the COMPOSITEslideId U+001F elementId(elKey) — bare ids repeat across slides (the morph idiom), each per-slide copy is its own node, the format never sees composite keys, and cross-slide moves diff as del+ins (ord never re-parents); state+wire are versioned (SYNC_V:vin SyncStateJSON,pvon frames — pre-v2 saved sync state and frames are discarded, the file rejoins as a fresh adopt); per-(node,key) LWW regs ordered by (lamport, actor); liveness = births vs tombs (an INS is a whole-node ASSIGNMENT — it resurrects by out-stamping the tomb and supersedes all older property regs; slide-ins = slide assignment + an independent element-ins per member, member processing must NEVER be skipped by the slide-level LWW race); pos = fractional base-62 keys (keyBetween midstrings, spreadKey for deterministic file adoption); dead-window values park in a reg-stampedstashand replay on resurrection ONLY while their reg is still the winner; element.html is a token RGA whose seed = (max(reg,birth).l, contentHash) so concurrent first-editors merge — a GENERATION duels plain sets as a unit, rebirths void only seeds BELOW their lamport; delivery = per-actor contiguouss- gap buffer +
pendingfor not-yet-known nodes (drain on every liveness event AND on txt progress — anchors/deletes can overtake their inserts). Convergence rig:node scripts/test-sync.ts(SEEDS/STEPS/ACTORS env) — run it after ANY crdt.ts change; it caught 15+ ordering bugs. Debug: setglobalThis.__dbgEl = '<node-id>'to trace one node through the gates.session.tsbridges the store: local commits → debounced (90ms) shadow diff → ops; remote ops apply surgically then re-emit the store events the editor already listens to (zero editor rewrites). Actor ids are fresh PER SESSION INSTANCE (a reloaded tab is a new replica; the engine skips "own" ops on apply — a persisted actor id would make relay replay a no-op). Same-machine tabs always sync (BroadcastChannelbento-sync-<docId>);online.tsadds the E2EE relay (AES-GCM, key indoc.collab.key, ?tok= hash-of-key possession proof; replay bookmark is MEMORY-ONLY — it's valid only with the CRDT state it was earned with). Credentials are minted AT CREATION (session.attach → mintCollab: RANDOM room id, never docId — re-share/fork rooms can't collide and the relay learns nothing), dormant untilcollab.on(absent = true for v0.8.0 files); "Rotate keys" is revocation. The saved FILE is the capability: opening a copy auto-joins (editor.connectSync joinIfShared). Saves stampdoc.collab.sync(SyncStateJSON via session.stampInto) so a copy edited OFFLINE rejoins as a true fork: restored regs defend its edits, relay replay vv-dedups, and a forksnapframe carries its state to peers (mergeSnapshot) — merge is two-way, verified under partition. hello replies include aneed(ops minted before connecting flow back); onRelayReady re-sends log ops the room's replay lacked. "Duplicate as new deck…" (About) = identity fork: new docId + fresh creds, never syncs with its ancestor. Relay:server/sync-worker/(blind DO, ciphertext-only storage — verified;npx wrangler dev --port 8787+ localStorage 'bento-sync-url' for local testing; NEVER pipe wrangler dev throughhead— SIGPIPE kills it). The DO MUST use the WebSocket Hibernation API (state.acceptWebSocket+webSocketMessage/webSocketClosehandlers,getWebSockets()for fan-out, per-socket state viaserializeAttachment) — plainserver.accept()keeps the invocation alive for the whole connection and throws "Exceeded allowed duration in Durable Objects free tier", which silently breaks ALL live collab (the bug behind v0.9.7). The relay must be redeployed (wrangler deploy) for worker changes to take effect — the app shell release does NOT touch it. Keepalive (v1.0.2): idle WebSockets get reaped by edge/ proxy idle timeouts, causing frequent connect/drop churn. The DO setssetWebSocketAutoResponse(new WebSocketRequestResponsePair('ping','pong'))(a fallbackdata==='ping'branch inonMessagecovers older runtimes), and the client (OnlineTransport) pings every 25s + reconnects fast if a pong is missed. The relay change is backward-compatible (old clients never ping), so deploy the relay BEFORE shipping a pinging client or the client would falsely reconnect-storm against a non-ponging relay. Undo under collab is snapshot-based and may revert concurrent remote edits to the same properties (documented LWW compromise). Fine-grained access v2 (v1.0.3, collab.v=2): the room id commits to an OWNER pubkey;mintCollabmints owner keys; "Invite to edit…" saves a copy carrying an owner-signed INVITE keypair (mintInvite); each device mints its own member key (localStoragebento-member-<docId>, never in the file) and joins via the owner→invite→member signature chain, verified PER SOCKET by the relay (wire params + sig texts in docs/collab-design.md "Phase 1 wire format"). Presence carries pub+role → key-bound People panel with per-member Remove (owner-signedrev.${pub}; client stands down on seeing its own key revoked). Reader copies strip ownerPriv+invite+writerPriv. Legacy rooms unchanged; deploy the relay BEFORE a client that depends on new verification. Guestbook is a v2 room now: public deck carries a PUBLIC writer invite (anyone writes, individually keyed); the OWNER deck lives in gitignored working/guestbook-live/guestbook-owner.bento.html (moderation = open it → People → Remove) — but only while the daemon is NOT auto-rolling, since every roll re-mints the room and orphans that owner file. It IS auto-rolling for launch (wrangler.tomlROLL_HOURS="0.5", cron*/30), so moderation is off until the cadence goes manual and a fresh pair is re-seeded. Cadence lives in server/guestbook-daemon/wrangler.toml, explained in that dir's README — never restate the numbers elsewhere; build-guestbook.mjs emits public + owner decks. Share exports (invite/viewonly/presentonly/template) pass a filename suffix and NEVER retain the FSA handle (writeUpdatedFileAsopts.keepHandle — retaining it made a later ⌘S overwrite the export with the full doc).
- gap buffer +
- 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-scrollwalks slides (threshold 40px + 400ms cooldown so a trackpad swipe = one slide; skips when the slide is zoomed/pannable). When an element IS selected, arrows still nudge it.store.goToLinear(±1)skips interactive states; PageUp/Dn route through it too. Wheel hook iscanvas.onSlideNav; ⌘/ctrl+wheel still zooms. - Readable default text (v1.0.2): new text/tables get a colour that reads on
the CURRENT slide —
readableInk(slide.background)(luminance test, dark bg → light ink#F5F7FA, else#1E2A3A);editor.newTable()also flips a dark slide's table borders/zebra light. Fixes invisible-on-dark-deck text. - Lines, curves & connectors (v1.0.2) —
editor/lineedit.ts. A selected line/curve is edited with DIRECT handles (not Moveable's box):LineEditorattaches fromcanvas.syncTargetswhen a single line/path shape is selected, hides Moveable, and shows draggable ENDPOINT handles (line) or ANCHOR handles (curve — double-click the body to add a point, a point to remove); dragging the body moves the whole thing. Geometry conversions: line ⇄ two endpoints (box centre ± ½w along rotation); path ⇄ anchors (pathBox normalised to [0,0,w,h],d= Catmull-Rom via patheditor'sanchorsToPath). The overlay ispointer-events:nonewith only handles interactive. Drawing: the Shape menu's Line/Curved line/Connector ARM a draw tool (canvas.armDraw) — drag on the canvas to draw (a crosshair overlay captures the gesture), click to drop a default. Connectors arelineshapes withfrom/to: {el, side}refs (model.tsConnectorEnd); a connector's end that lands on an element anchors to it, andeditor.syncConnectors()(on thedocevent, mirrors syncLinkedCharts — derive-not-commit, re-renders) recomputes each anchored endpoint on the element's border toward the other end whenever anything moves. Dragging an endpoint by hand detaches that end. Dangling refs are dropped. src/editor/— vanilla-TS editor. Moveable + Selecto handle manipulation. Interaction modifiers: Shift = keep-ratio resize / axis-locked drag / 15° rotate snap; Alt/Option = resize from CENTER (deep-select exempts Moveable's control box, so Alt-on-a-handle means center-scale); ⌘/Ctrl-drag = duplicate (originals move, copies stay — one undo step; Alt on element bodies stays deep-select). Selecto's container/ dragContainer is the SCROLLER (not the stage) — marquees must be able to start on the grey surround, the natural gesture; floating controls (FABs/zoombar/chevrons) are exempted in the selecto dragStart guard. Pane testing: synthetic drags on Moveable HANDLES don't register at all (verified against pre-change code too) — resize behavior needs a real mouse. Present: real fullscreen via overlay.requestFullscreen at start + F toggle (denied requests degrade to tab-fill — that IS the testing/ sharing mode). Topbar is responsive by HIDING TEXT, never scrolling — and by MEASURING, not width breakpoints (zoom/OS text scale/locale width made px queries clip the bar): editor.ts fitTopbar() steps down tier classes while the bar overflows (ed-bar-compact hides labels, ed-bar-tight the wordmark, ed-bar-fold folds into menus via applyPhoneChrome), driven by a Resize- + MutationObserver on the bar. Panel show/hide lives ON the resizer strips as chevron tabs (docked flush to the screen edge when collapsed); phones (<700px) boot with both panels collapsed (canvas-first; chevrons/[/] bring them back). Update chip sits beside the wordmark and exists ONLY when an update is found. Present lives as FLOATING buttons at the canvas's BOTTOM-LEFT (zoombar owns the right corner): big round FAB = fullscreen present, small one BESIDE it (never above — it would cover the slide) = tab-fill mode (testing/window-sharing); both call editor.present( fromStart, fullscreen) → startPresentation opts.fullscreen. Leaving fullscreen (Esc/F/browser UI) ENDS the show — it never drops to tab-fill (fullscreenchange listener; tab mode only via the small FAB). Touch: our own swipe nav (Reveal touch OFF — it would walk into hidden states); swiping past either end exits to the editor. Deck PAGE SIZE: presets + custom in the slide panel (doc.size — already per-doc in the format); canonical 16:9 = 1280×720 (matches the model default; presets use exact-px matching); print @page is generated per-deck (width normalised to 1600, height follows aspect). Size changes reframe the canvas, never rescale elements. FULLSCREEN paints only the fullscreened element's subtree — anything body-mounted (chart tooltips!) must reparent into document.fullscreenElement while it's active. Save is a dropdown: copy (identity-keeping invite) / new deck (fresh identity) / template. "Live" button popover: name field, collaborator list (click follows), join/leave toasts. Deck buttons use ONE invisible hit-rect above button+label (hard-won #7) — never putlinkon both a rect and its label text (two mismatched hover pills). Alt/Option-click digs through overlapping elements (capture-phase, beats Moveable's control box). Fill/stroke colors carry alpha (color input + % pair, rgba round-trip); shapes supportfillGradient(linear, CSS-convention angle, multi-stop) — rendered as per-instance svg<defs>gradients (unique ids: url(#…) refs are document-global across canvas/thumbs/present). Arrange kit in the panel: align/distribute/z-step + Group (⌘G/⇧⌘G —groupId, distinct from presentationgroup; click selects the group, Alt-click reaches a member; duplicate remaps groupIds). Text editing supports ⌘B/I/U plus markdown autoformat (editor/markdown.ts: bold italiccodestrikeand "- " bullets collapse as typed — mind contentEditable NBSPs and stale Selection offsets after DOM surgery; pasted plain text converts too). Escapes: backslash before a marker keeps it literal (stripped at commit); ⌘Z immediately after a conversion restores the typed markers (one-shot, cleared by the next input). Note markdown.ts carries LITERAL invisible chars (NBSP, ZWSP, U+E000) — they don't survive retyping; edit that file with line-targeted scripts, not copy-typed strings. Sidebar has hover insert-gaps between thumbnails (never between a parent and its states). Selecto's continue-select list is synced in syncTargets — stale cross-slide shift-click selections were a real bug.
What this file has done since we first saw it
Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.
- 4d ago First seen · 685 lines · 12,831 tokens per session scan A 39c57bcf9579
bento CLAUDE.md is an instructions file published in the GitHub repository nyblnet/bento (4,682 stars, last pushed today), licensed MIT. It adds 12,831 tokens to every session, about $0.0642 per session on Opus 5. A static security scan graded it A with 0 findings. No closer match exists in the catalogue, so it is treated as the original; first seen 2026-08-30.
Other instructions, from other repositories
ppt-master AGENTS.md
AGENTS.md instructions for hugohe3/ppt-master, covering agents.md, project overview, execution requirements, required conventions and compatibility boundary.
ppt-master CLAUDE.md
Claude Code instructions for hugohe3/ppt-master: Claude Code entry point. To avoid maintaining two copies, all project instructions live in a single source of truth — AGENTS.md — imported below (Claude Code inlines @-referenced files into context). Always edit AGENTS.md; never duplicate its content here.
genoffice CLAUDE.md
Claude Code instructions for genspark-ai/genoffice, covering claude.md, theming rules (mandatory) and build gotchas.
drizzle-cube CLAUDE.md
Claude Code instructions for cliftonc/drizzle-cube, covering claude.md, project structure, agent documentation context, database support and essential commands.
macro AGENTS.md
AGENTS.md instructions for macro-inc/macro, a project described as: Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
aristo CLAUDE.md
Instructions for aretta-ai/aristo, covering claude.md — aristo working agreement, §1. commit size — small or medium only, §2. commit messages — semantic / conventional, §3. changelog.md — one line per commit, in the same commit and §4. test-first — no test, no claim of correctness.