Math: render $…$ as MathML via Temml (#15, #33) - #76
Merged
Conversation
This was referenced Jul 25, 2026
Math classes were the ask (#15) and $…$ the requested syntax (#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; #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 < 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.
nyblnet
force-pushed
the
claude/math-temml
branch
from
July 26, 2026 00:23
6e116f9 to
053dff7
Compare
nyblnet
added a commit
that referenced
this pull request
Jul 26, 2026
A term crossing the equals sign is now SEEN to travel there, instead of the whole formula crossfading. The idea comes from PR #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 #76 (Temml math).
nyblnet
added a commit
that referenced
this pull request
Jul 26, 2026
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 #83 (symbol morph) -> #76 (Temml). MUST NOT land before #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$.
kaung-minkhant
pushed a commit
to kaung-minkhant/bento
that referenced
this pull request
Jul 26, 2026
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).
kaung-minkhant
pushed a commit
to kaung-minkhant/bento
that referenced
this pull request
Jul 26, 2026
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$.
nyblnet
added a commit
that referenced
this pull request
Jul 26, 2026
Both landed on main with no user-facing note, so 1.0.11 would have announced "we fixed some save messaging" while quietly containing LaTeX maths, symbol- level formula morphing and twenty-one installable languages. The release notes are also what the About dialog now shows inline before an update, so an empty entry is a silently undersold release. Written from the merged PRs (#76, #83, #84 for maths; #81, #86, #91 for packs) and then CHECKED AGAINST THE CODE, which corrected three things the PR prose would have left wrong: - The menu is "Manage languages…", not "Add or remove languages…". - Korean is no longer "the first pack" — there are 21 on disk (ar, bn, da, fa, fi, he, hi, id, ko, ms, nb, nl, pl, ru, sv, th, tl, tr, uk, ur, vi). - Maths does NOT render "anywhere text does". resolveMath has exactly one call site, the TEXT element path in render.ts — not table cells, not speaker notes. Claiming otherwise would have sent people to try it in a table.
nyblnet
added a commit
that referenced
this pull request
Jul 27, 2026
A term crossing the equals sign is now SEEN to travel there, instead of the whole formula crossfading. The idea comes from PR #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 #76 (Temml math).
nyblnet
added a commit
that referenced
this pull request
Jul 27, 2026
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 #83 (symbol morph) -> #76 (Temml). MUST NOT land before #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$.
nyblnet
added a commit
that referenced
this pull request
Jul 27, 2026
Both landed on main with no user-facing note, so 1.0.11 would have announced "we fixed some save messaging" while quietly containing LaTeX maths, symbol- level formula morphing and twenty-one installable languages. The release notes are also what the About dialog now shows inline before an update, so an empty entry is a silently undersold release. Written from the merged PRs (#76, #83, #84 for maths; #81, #86, #91 for packs) and then CHECKED AGAINST THE CODE, which corrected three things the PR prose would have left wrong: - The menu is "Manage languages…", not "Add or remove languages…". - Korean is no longer "the first pack" — there are 21 on disk (ar, bn, da, fa, fi, he, hi, id, ko, ms, nb, nl, pl, ru, sv, th, tl, tr, uk, ur, vi). - Maths does NOT render "anywhere text does". resolveMath has exactly one call site, the TEXT element path in render.ts — not table cells, not speaker notes. Claiming otherwise would have sent people to try it in a table.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #15, closes #33 (duplicates of each other).
Approach
Resolve
$…$and$$…$$at render time — the same trickresolveFieldsalready uses for{{page}}.The format doesn't change. The model stores the raw source;
$E=mc^2$is just text inel.html. Nothing to version, 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 becauserender.tsis shared. This is option 4 from #33's list, and it makes option 3 (anequationelement) unnecessary.Why Temml, not KaTeX or MathJax
Measured through the real storage pipeline (deflate + the base64 the shell stores payloads in):
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 woff2 faces — already-compressed binaries that deflate can't shrink and base64 makes bigger. KaTeX would put the shell back over 1MB, the territory this project left when ECharts was removed.
One correction for #15: MathJax isn't ~15KB — its npm package is 20MB unpacked, and minimal component builds still run to hundreds of KB plus fonts.
Safety
resolveMathruns aftersanitizeHtml, 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. Temml runs withtrust: false, so\hrefand friends are inert.The inline form is deliberately fussy so prose survives: no whitespace just inside the delimiters, and no digit straight after the closer. That's what keeps
it costs $5 and $10from parsing as math.Verification
Seven cases in the browser:
$E=mc^2$<math>, inline$$\frac{-b \pm …}{2a}$$<math display="block">it costs $5 and $10 today\$99$99$\frac{1}{$(broken TeX)$x < y$x < y<b>bold</b>In every case
el.htmlstill holds the raw source. Double-clicking shows$E = mc^2$with zero<math>nodes while editing —canvas.startTextEdit's raw-swap now triggers on$as well as{{.Splice conformance gate passes;
tsc -bclean.Cost — and a correction
+80,173 B (+13.4%), not the +64KB I projected from temml's prebuilt
distfile. Vite bundles the package source rather than that artifact.Measured combinations:
So together the shell grows ~24KB rather than shrinking ~19KB as I earlier estimated. #75 pays for most of the math, not all of it. Worth landing #75 first either way.