Skip to content

feat(slides): export editable PowerPoint files - #88

Open
bluegitter wants to merge 33 commits into
nyblnet:mainfrom
bluegitter:pr/pptx-export
Open

feat(slides): export editable PowerPoint files#88
bluegitter wants to merge 33 commits into
nyblnet:mainfrom
bluegitter:pr/pptx-export

Conversation

@bluegitter

Copy link
Copy Markdown

Summary

  • add an editable PPTX export path for bento/slides decks
  • map common slide primitives, images, media placeholders, tables, charts, links, and speaker notes into PowerPoint output
  • document current export coverage and include PptxGenJS in bundled notices

Commit structure

  • fix(build): extract stylesheet outside bundled scripts
  • feat(slides): export editable PowerPoint files

Validation

  • npx -y node@24 scripts/build-i18n.mjs --check
  • cd slides && node_modules/.bin/tsc -b
  • cd slides && node_modules/.bin/tsc -p ../kernel
  • cd slides && npm run build:single
  • node scripts/shell-gate.mjs slides/dist-single/Bento_Slides.bento.html

nyblnet and others added 30 commits July 26, 2026 01:05
Publish: create the GitHub release automatically
Update save picker: suggest the open file's own name
Add "Start from scratch…" — reset a deck to one blank slide (nyblnet#31)
Setting a morph id was one click; CLEARING it was not. The documented route
is to retype the element's own id into the "Morph id" field — which means
knowing what that id was, when the field is now showing the override. The
"Pair with" picker could set a pairing but never undo one. Reported as part
of nyblnet#54 ("impossible to clear"); the rendering half of that issue shipped in
1.0.10, this is the affordance half.

Adds a "Don't pair — use its own id" option to the picker, shown only when
an override is actually set. It routes through the existing setMorphId with
the element's own id, so the clearing rule stays in ONE place.

Two details:
  - The picker was gated on `targets.length`, so an element that was paired
    but had no other slide to pair with rendered no picker at all — and
    therefore no way back. Now `targets.length || el.morphId`.
  - The sentinel option value is '#unpair', collision-proof by construction:
    setMorphId strips everything outside [A-Za-z0-9._-], so no stored
    morphId can ever contain '#'.

Left alone deliberately: the hint text still says the field route works,
because it does. Rewording it would orphan its seven existing translations
to no benefit.

VERIFIED in the browser (real mouse click for selection — synthetic events
don't drive Selecto), on a deck whose slide-2 shape carries morphId
'src-shape':
  - option appears, labelled and valued as expected
  - choosing it clears morphId, with no false collision warning
  - the picker rebuilds WITHOUT the option once unpaired
  - ⌘Z restores the pairing

i18n: 1 new string in all 7 catalogs, matching each catalogue's existing
"Pair with" phrasing. Verified through the real lookup path — setLocale +
t() returns a translation, not the English fallback, in every locale.
tsc -b clean.
Morph panel: an explicit way to unpair (nyblnet#54 follow-up)
The lowercase rebrand (2b3214f) swept the DECK's title along with the app's.
Those are different things: a deck title is a DOCUMENT name — it shows in the
window title and is what suggestedFileName() turns into a filename — so it
belongs in title case. The bento/slides wordmark, the About tooltip, the
on-slide BENTO/SLIDES kicker and every other piece of app chrome stay
lowercase and are untouched.

Checked the rest of that rebrand commit: this was the ONLY document title it
lowercased. Everything else it changed reads through appConfig().appName (app
chrome, correctly lowercase), and newDoc() still defaults to 'Untitled'.

VERIFIED in the browser: deck title "Bento Slides Showcase", window title
"Bento Slides Showcase — bento/slides", suggested filename
Bento_Slides_Showcase.bento.html, wordmark still bento/slides.
Starter deck: restore the "Bento Slides Showcase" title
Stage 1 of working/plan-browser-reach.md.

Safari, Firefox, and EVERY browser on iOS (all WebKit) lack the File System
Access API. Without it there is no writable handle, which costs in-place save,
silent autosave write-back, and in-place self-update — saving hands back a copy
instead.

The degradation is unavoidable. Asserting the opposite was not: before any save
happened the editor said "Save — rewrite this file in place (⌘S)" and "Unsaved
changes — ⌘S saves this file in place", and only corrected itself in a toast
AFTER the first save — by which point an author who opened a deck from disk had
already trusted it and had no idea their edits went to Downloads.

- kernel: canWriteInPlace(), exported so UI cannot promise what the browser
  cannot do.
- Save button and dirty-dot tooltips describe the real behaviour per browser.
- A one-time notice before any work is at stake, dismissible, keyed per BROWSER
  (bento-save-notice) rather than per deck — it is a property of the browser,
  and repeating it for every file would be nagging. Reuses the existing
  .ed-recover banner rather than inventing a surface.
- Four new strings across all seven catalogs (machine-drafted, per the standing
  note in each file — native review welcome).

Chrome/Edge behaviour is untouched.

VERIFIED in the browser both ways, with the API deleted BEFORE boot rather than
after (the tooltips are built during boot, so a post-boot delete proves
nothing): without FSA both tooltips switch and the notice appears; dismissing
sets the flag; with FSA present the old wording stands and no notice shows.

Rig SEEDS=200 ALL PASS. Splice gate OK.
Second half of stage 1 (working/plan-browser-reach.md).

Bento has always snapshotted the deck to IndexedDB as you edit and offered it
back on reopen. On Safari/Firefox/iOS that snapshot is the ONLY thing standing
between the author and lost work — and it was completely invisible: autosave
wrote the snapshot, the `hasFileHandle()` branch was skipped, and nothing was
shown. The sole signal was an amber dirty dot that never cleared, which reads
as "your work is at risk", the opposite of the truth.

- flashSaved() takes a message; the no-handle path flashes "Backed up in this
  browser" instead of nothing.
- The dirty dot's tooltip carries the time of the last backup, so hovering the
  thing that is worrying you explains itself.
- The deck STAYS DIRTY and the dot stays amber. The file on disk really is
  stale; clearing it or saying "Saved" would trade one lie for another. The
  claim is precisely "recoverable", not "saved".
- SILENT for an encrypted deck. Those are never snapshotted (plaintext-to-disk),
  so on a browser that cannot write back there is no backstop at all, and
  claiming one would be the worst version of this bug.

VERIFIED with the FSA API deleted before boot: a real toolbar insert triggers
autosave, the tag reads "Backed up in this browser", the tooltip reads "kept in
this browser at 23:08 …", and the deck remains dirty. The encrypted path was
verified by reading the gate rather than driving the password UI — `snapshotted`
is only set when !isEncryptionActive() and the claim is gated on it.

Two new strings across all seven catalogs.
Rig SEEDS=200 ALL PASS. Splice gate OK.
…ckup

Two iOS-first fixes. Phones are where a shared deck usually gets presented and
opened, which makes them the sharp end of the reach problem.

1. SCREEN WAKE LOCK (present.ts). Nothing held the screen on, so iOS dimmed and
   locked on its own idle timer mid-talk — a presenter who leaves a slide up for
   two minutes loses the display. Desktop benefits too: fullscreen alone does
   not defeat a screensaver. Best-effort by construction — absent on iOS <16.4
   and Firefox, and the request is refused unless the page is visible, so it can
   never throw into the caller. The browser also drops the lock whenever the tab
   is hidden, so it is re-acquired on visibilitychange: backgrounding the app
   mid-show and returning would otherwise leave the screen sleeping again.

2. putRecovery NOW REPORTS WHETHER IT ACTUALLY STORED — fixing a defect in the
   backstop indicator added in the previous commit. `tx()` resolves null on
   every failure rather than throwing (no IndexedDB, blocked open, failed
   transaction), which is right for a best-effort backstop but meant
   `await putRecovery(doc)` "succeeded" even when nothing was written. The
   editor then flashed "Backed up in this browser" with no backup behind it.
   Safari private browsing and some file:// contexts have no usable IndexedDB,
   and on iOS that is precisely where a shared deck gets opened — so the bug
   would have fired hardest exactly where the claim matters most. It now returns
   a boolean and the editor only claims a backstop that exists.

VERIFIED in the browser with a stubbed wakeLock: acquire on show start, release
on exit, and no double-acquire when a lock is already held. The initial acquire
correctly no-ops while the page is hidden and takes the lock on the following
visibilitychange — which is the phone-backgrounding path.

Rig SEEDS=200 ALL PASS. Splice gate OK.
Say what saving actually does on browsers without File System Access
Adds a "What’s new →" link to the update block, pointing at that version's
GitHub release page. The manifest carries no notes today (release.mjs sets
none), so a link is the honest option — and publish-site.mjs now creates a
release for every version, so it cannot dangle. Placed BEFORE the action
buttons: reading before deciding is the point.

ALSO FIXES A PRE-EXISTING LAYOUT BUG the screenshot exposed. .ed-about is a
height-capped column flex (max-height: 88vh, scrolls). .ed-about-status kept
the default flex-shrink: 1, so as soon as an update WAS available — heading,
link and two buttons — the block collapsed to exactly its 18px min-height and
its children spilled over everything below, overlapping the auto-check row,
the offline switch and Document properties. Measured 18px tall holding 145px of
content. flex-shrink: 0 lets it take its natural height; the dialog scrolls,
which is what its overflow-y is already for.

That bug was live for anyone who had an update waiting — i.e. exactly the
moment this dialog matters.

VERIFIED against a locally signed 1.0.11 manifest: the link renders with
href .../releases/tag/v1.0.11, target=_blank, rel=noopener, no underline, and
sits between the version notice and the buttons. Status block now measures
144px, last child ends at 323, next section starts at 335 — no overlap.
Screenshot confirms.

Two new strings across all seven catalogs.
Rig SEEDS=200 ALL PASS. Splice gate OK.
About: link to the release notes when an update is available
The catalogs are gettext-style: the English source string IS the key. Great
for authoring — readable source, free fallback — but it means every English
sentence was stored SEVEN times, once per locale.

Measured before this change: 135KB of duplicated English keys against 160KB
of actual translations. Keys were 46% of the catalog payload, and the
catalogs were 443KB — 36% of the entire minified bundle, more than every
third-party dependency combined (Moveable ~240KB, Reveal 109KB, Selecto
21KB).

Deflate cannot rescue this. Its window is 32KB and the catalogs span 320KB,
so by the time zh-Hant repeats a key, de's copy is far out of range. The
duplication survives compression essentially intact.

WHAT SHIPS is now one key-once table — the English key appears once, with
translations positional by locale:

    "Delete slide": ["スライドを削除", "删除幻灯片", …]

WHAT IS AUTHORED is unchanged. The per-locale files in slides/src/i18n/ stay
the source of truth: they are what a translator reviews and what a
contributor submits (issue nyblnet#17 is an offer of a Korean catalog — nobody
should have to hand-edit a positional array). scripts/build-i18n.mjs packs
them; slides/src/i18n/packed.ts is generated and marked DO NOT EDIT.

The generator EVALUATES the catalog modules rather than parsing them — an
early regex-based attempt silently missed 54 of 650 entries.

Kernel: registerI18n now takes `packed` as an alternative to `catalogs`, so
apps still using plain catalogs (spaces) are unaffected. Locale resolution
and lookup route through hasLocale()/lookup() so both shapes behave
identically. Chinese aliases (zh, zh-CN, zh-SG, zh-TW, zh-HK) moved from
duplicate object references to an explicit alias map.

RESULT: shell 598,457 B -> 541,930 B (-56,527 B, -9.4%); js payload
549KB -> 493KB.

VERIFIED:
  - all 4,550 translations (650 strings × 7 locales) round-trip EXACTLY
    against the original per-locale catalogs — no lost, no invented, no
    column misalignment
  - in-browser: all 8 picker locales resolve; zh/zh-CN/zh-SG -> simplified
    and zh-TW/zh-HK -> traditional; {placeholder} interpolation intact on
    translated strings; unknown keys fall back to English; x-pseudo works
  - CI gains `build-i18n.mjs --check`, and the gate was PROVEN to fail on a
    deliberately stale table (exit 1), not merely observed to pass
  - splice conformance gate passes on the new shell; kernel standalone
    typecheck clean

spaces/ deps aren't installed in this worktree so its build wasn't run
locally — CI covers it, and its registerI18n call is unchanged.
nyblnet#72, nyblnet#73, nyblnet#74 and nyblnet#82 each added UI strings while this branch was open. The
generated table is gated in CI, so it has to be rebuilt against them: 650 -> 662
strings. Verified all 4,634 translations round-trip against the per-locale
catalogs.
i18n: pack the catalogs key-once — 55KB off the shell
Docs: language packs — bundled core of 9, everything else signed packs
Portuguese joins the bundled core per docs/i18n-packs.md: Brazil has a
genuine English-proficiency gap — unlike the markets where a localized UI
mostly goes unused — and pt sits in the cheapest cost tier. It is the only
language being added to the core; everything else becomes a pack.

Brazilian terminology throughout: arquivo (not ficheiro), tela (not ecrã),
salvar (not guardar), compartilhar (not partilhar). pt-PT aliases here for
now — European Portuguese diverges enough to deserve its own catalog, and it
is a natural first PACK once that machinery exists (noted in the file header).

Five strings were generated programmatically from the canonical keys rather
than hand-typed: the markdown-help and embed-size confirms carry backslash and
newline escaping, and two PAIRS of them differ from each other ONLY in that
escaping. A mistyped escape produces a key that silently never matches, so
hand-copying them was the one part worth not doing by hand.

COST: +11,832 B (+2.2%), close to the ~14 KB per-language rule and in the
cheapest tier as predicted. With the key-once packing this branch builds on,
the shell is still 44,695 B (-7.5%) SMALLER than main today.

VERIFIED: all 650 strings resolve through the REAL lookup path — zero fell
back to English, zero mismatched against the source catalog. Picker shows 9
locales; pt, pt-BR and pt-PT all resolve. Interpolation and inline HTML
survive. Confirmed visually in the running editor. build-i18n.mjs --check
current, splice conformance gate passes, tsc -b clean.

Machine-drafted, native review welcome — as the file header says, and as every
other catalog here says.
…open

nyblnet#72, nyblnet#73, nyblnet#74 and nyblnet#82 each landed new UI strings after this catalog was
written. Without these twelve, Portuguese would have silently fallen back to
English on the save-capability wording, the release-notes link, Start from
scratch and the morph unpair option. 662/662 now, verified against the packed
table.
i18n: add Portuguese (pt-BR) to the bundled core
Builds the machinery from docs/i18n-packs.md so that adding a language stops
costing shell bytes. Korean is the first pack and the proof it works.

WHY THIS BEFORE MORE CONTENT. Every i18n change in this series was verified
the same way: call t() through the REAL lookup path and confirm it returns a
translation rather than the English fallback. Without a loader that check is
impossible, so generating catalogs first would mean thousands of machine-
drafted strings with ZERO evidence any of them resolve — and a mistyped key
falls back to English silently, forever. Pipeline first makes every language
after this verifiable.

KERNEL. addPack() layers a pack over the bundled core, keyed by language.
  - Packs are consulted BEFORE the core, so a pack can also CORRECT a bundled
    language without cutting an app release — which is what turns native
    review into a continuous process instead of a release blocker.
  - Deliberately tolerant: a pack built against an older app simply lacks the
    newer keys, and each miss falls through to the core and then to English.
    A stale pack degrades string by string instead of failing to load.
  - Rejects a pack built for a DIFFERENT app: its keys would be wrong, not
    merely missing, which is the one case worth refusing outright.
  - registerI18n re-appends pack languages to the picker, because block order
    in the shell isn't guaranteed and a pack loaded BEFORE registration would
    otherwise lose its entry.

BUILD. `build-i18n.mjs --packs <dir>` emits one JSON per language from
slides/src/i18n/packs/*.ts — authored exactly like a core catalog, but nothing
imports that directory so it never enters the module graph. It errors if a
language is listed as both bundled and packed.

COST: the pipeline adds 200 B to the shell. The Korean pack is 7,207 B and
lives entirely OUTSIDE it.

VERIFIED end-to-end in the browser:
  before loading   ko absent from the picker; t('Delete slide') = English
  after addPack()  ko in the picker labelled 한국어; '슬라이드 삭제', '저장',
                   '공유'; interpolation intact ('슬라이드 4')
  degradation      'Version history…' — a key this partial pack does NOT
                   carry — correctly returns English while carried keys
                   resolve in Korean
  wrong app        a pack marked app:'spaces' is rejected
Korean is absent from packed.ts (not bundled). Core --check current, splice
conformance gate passes, app + kernel typecheck clean.

KOREAN IS PARTIAL: 262 of 650 strings, covering the most visible UI. That is
a VALID pack by design — the rest fall back per string — and completing it is
the obvious next step. Issue nyblnet#17 is a volunteer offering to write this
catalog; they should be offered review and ownership of it.

Stacked on nyblnet#79 (Portuguese) -> nyblnet#75 (key-once packing).
Finishes what the previous commit left at 262. There was no technical reason
to stop there; it was a stopping point, not a design decision.

The pack is now 58,137 B of JSON and still contributes NOTHING to the shell —
Korean remains absent from packed.ts, which is the whole point of the pack
architecture.

The four escape-sensitive strings were generated from the canonical catalog
rather than hand-typed: two PAIRS of keys differ from each other only in
backslash/newline escaping, and a mistyped escape yields a key that silently
never matches. The assembler also fails on any key that is missing OR extra,
so the pack cannot drift from the catalog it targets.

VERIFIED: all 650 strings resolve through the REAL lookup path — zero fell
back to English, zero mismatched against the pack source. ko appears in the
picker as 한국어; interpolation and inline HTML survive. Core --check current,
splice conformance gate passes, tsc -b clean.

Still machine-drafted. nyblnet#17 is a volunteer who offered to write this catalog
and should be offered review and ownership of it — a pack can be corrected
and re-released without an app release, so their review is not blocked on
anything here.
Same twelve as Portuguese — nyblnet#72, nyblnet#73, nyblnet#74 and nyblnet#82 landed new UI strings after
the pack was written. A pack degrades per string by design, so these would
have fallen back to English rather than broken anything, but a pack should not
arrive already incomplete. 662/662.
i18n: language pack pipeline, with Korean as the first pack
Math classes were the ask (nyblnet#15) and $…$ the requested syntax (nyblnet#33). This
resolves both at RENDER time, the same trick resolveFields already uses for
{{page}}.

THE FORMAT DOESN'T CHANGE. The model stores the raw source — `$E=mc^2$` is
just text in el.html — so there is nothing to version and no new element
type. An older build opening a newer file shows the literal `$E=mc^2$`:
degraded, legible, nothing lost. One renderer covers canvas, thumbnails,
present and print because render.ts is shared.

WHY TEMML, NOT KATEX/MATHJAX. Measured through the real storage pipeline
(deflate + the base64 the shell stores payloads in):

    KaTeX     +421KB   js 100KB + css 5KB + 326KB of webfonts
    Temml      +64KB   no fonts at all
    MathJax   larger still (20MB npm package; nyblnet#15's "about 15 kB" is not right)

Temml emits MathML and the browser lays it out with its own math fonts;
KaTeX emits HTML+CSS so it must ship a layout engine AND ~20 font faces,
which are woff2 — already compressed, so deflate can't help and base64 makes
them bigger. KaTeX would put the shell back over 1MB, the territory the
project left when ECharts was removed.

ORDER MATTERS: resolveMath runs AFTER sanitizeHtml, never before. The
sanitizer unwraps everything outside its allowlist and strips all attributes,
so it would demolish MathML. Running after is also why the allowlist needs no
widening — this markup is GENERATED by us from LaTeX, never accepted from the
author — and Temml runs with trust off, so \href and friends are inert.

The inline form is deliberately fussy, because prose has to survive: no
whitespace just inside the delimiters, and no digit straight after the
closer. That is what stops "it costs $5 and $10" parsing as math. \$ is a
literal dollar, and TeX that fails to parse is left exactly as typed.

Editing shows the RAW source, like fields: canvas.startTextEdit's raw-swap
now triggers on `$` as well as `{{`.

VERIFIED in the browser, seven cases:
  inline $E=mc^2$                    -> one <math>, inline
  display $$\frac{-b\pm…}{2a}$$      -> one <math display="block">
  "it costs $5 and $10 today"        -> NO math, text untouched
  escaped \$99                        -> literal $99
  broken TeX $\frac{1}{$              -> left exactly as typed
  entities $x &lt; y$                 -> decoded, renders x < y
  math beside <b>bold</b>             -> both survive
In every case el.html still holds the raw source. Double-click shows
`$E = mc^2$` with zero <math> nodes while editing.

COST: shell 598,457 B -> 678,630 B (+80,173 B, +13.4%). Higher than the
+64KB predicted from temml's prebuilt dist — vite bundles the package source,
not that file. Splice conformance gate passes; tsc -b clean.
A term crossing the equals sign is now SEEN to travel there, instead of the
whole formula crossfading. The idea comes from PR nyblnet#68; this implements it on
the bundled Temml renderer rather than a lazy-loaded library.

HOW IT PAIRS. render.ts tags each MathML token with its own text plus an
occurrence index (`x#0`, `x#1`) — so the second `x` in one formula pairs with
the second `x` in the next, wherever each has moved to. Render-time only:
nothing about it enters the document.

THE HARD PART was geometry, and it needed care because it cuts against the
engine's central rule. runMorph reads frames from the MODEL and never
measures the DOM, precisely because the outgoing section carries Reveal's own
transforms. A symbol inside a formula has NO model entry — it is produced at
render time from a raw `$…$` string — so there is nothing to look up.

Two things make it work:

  1. Measure only what is INVARIANT under those transforms: a symbol's offset
     from its own element's box, divided by that box's measured size over its
     MODEL size. Any scale an ancestor applies hits numerator and denominator
     alike and cancels. Per-axis, because the box tween can scale x and y
     differently and one shared factor skews every vertical offset.
  2. The outgoing slide cannot be measured AT ALL at morph time — verified,
     its elements report zero width. So each slide's symbols are captured
     while it is ON SCREEN and cached by (slide index, flip id); the morph
     reads the cache for the from-side. Captured synchronously, not in rAF: a
     backgrounded tab never runs animation frames.

Box geometry stays entirely model-driven. Only rearrangement WITHIN a box is
measured.

COMPOSES with the box morph rather than replacing it — that tween already
carries gross position and scale, so only each symbol's relative offset is
animated here, divided by the box's current scale (a transform on a child of
a scaled parent is scaled too, and without this symbols overshoot whenever a
formula changes size between slides).

Degrades quietly: symbols on only one side, formulas that share nothing, and
slides never measured all fall back to the plain box morph.

VERIFIED with the manual clock on `$a + b = c$` -> `$a = c - b$`:
  b   -59.6px -> -7.9px -> settles   (starts LEFT of its end: travels right,
                                      across the equals sign)
  =   +59.3px -> +7.8px -> settles   (starts RIGHT: travels left)
  c   +59.3px -> +7.8px -> settles
  a   no tween — first in both formulas, correctly seen as stationary
  −   only in the second formula — left to the box morph
Frozen mid-flight, `a` sits settled while b/= /c are caught crossing.
symCache is cleared on exit: it is keyed by slide INDEX, so carrying it into
a later show would be wrong if the deck was edited in between.

COST: +804 B. Splice conformance gate passes, tsc -b clean.
Stacked on nyblnet#76 (Temml math).
Symbol-level maths morph is invisible unless you see it move — a screenshot
cannot show it, and the starter deck is the feature tour. So it gets one beat.

DELIBERATELY SMALL. The formula sits quietly under the caption on the existing
"Morph." slide, then becomes the point of ONE new slide where it rearranges:

    $a + b = c$   ->   $a = c - b$

Same element id on both, so `b` is SEEN to travel across the equals sign while
the four sd-tile-* shapes continue their own morph. This extends the morph
story rather than opening a separate "maths" topic — the tour is shared by
everyone, and a slide teaching LaTeX would spend attention on behalf of users
who do not need it. Depth belongs in a gallery deck, not here.

+564 B in every shipped shell.

The caption writes the delimiters as \$…\$ ON PURPOSE. Writing them bare
renders them as maths — which is exactly what happened on the first attempt,
turning "plain $…$ in a text box" into "plain … in a text box" with the
ellipsis silently converted to a formula. It is the first thing anyone copying
this deck will hit, so the deck should show the escape rather than trip over
it.

VERIFIED on the real deck transition, driving anim's manual clock (the preview
pane never fires rAF, so morphs freeze mid-flight and screenshots of them are
not the settled state):
  b   -250.4px -> -11.0px -> settles
  c    -52.7px -> -2.3px  -> settles
  =      2.6px -> 0.1px   -> settles
  a      5.5px -> 0.2px   -> settles
b's horizontal travel is an order of magnitude larger than any other symbol —
the signature of a term crossing the equals sign. Caption renders literal
"$…$" with no <math> in it. Splice conformance gate passes, tsc -b clean.

Stacked on nyblnet#83 (symbol morph) -> nyblnet#76 (Temml). MUST NOT land before nyblnet#76: the
starter deck ships inside every build, so maths content without the renderer
would make every fresh Bento open showing a literal $a + b = c$.
`a + b = c` proved the mechanism but showed nothing of the typesetting. The
quadratic formula is the one equation everyone recognises, and it exercises
the whole renderer in a single glance — fraction, radical, superscript, ±:

    $ax^2 + bx + c = 0$   ->   $$x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}$$

Display mode ($$) on the hero so the fraction and radical set at full size.

It also makes a far better morph. a, b and c do not sit still and recolour —
they fly out of the quadratic and into the discriminant and the denominator.
Measured 18% into the transition:

    a   573px of travel        c   430px
    2   361px                  =   137px
    b    89px                  x    12px (mostly vertical)

All settled by 68%. The old example moved one symbol 250px; this moves four
symbols several hundred each, which is the difference between "you can see it
worked" and "that is worth showing someone".

Speaker notes rewritten to match — they described b crossing an equals sign
that no longer exists — and now say the useful part: the document stores the
LaTeX, not a picture, not a font, with nothing fetched at runtime.

+780 B over the symbol-morph branch. Splice conformance gate passes, tsc -b
clean. Verified rendered (mfrac, msqrt, msup, display=block present), the
before-state still renders as maths on the previous slide, and the caption
still shows a literal escaped $…$ with no <math> in it.
@nyblnet

nyblnet commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Thanks for submitting this! Powerpoint export is one of the most asked features but I'm still trying to figure out the best way to handle it without making the runtime significantly larger. I'm also worried that people will use Bento as a pptx creator which shouldn't be the case. A lot of the nice visual elements will be los in Pptx format, but also one pptx deck shared is one less Bento deck shared. I'll try to find a middle way here and will use your PR as a base.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants