Making proofs morph, not snap — four fixes to the animation engine #455
ibenian
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Over the last few PRs we went on a focused hunt to make AlgeBench's proof animations morph smoothly instead of snapping. The engine tweens one step of a derivation into the next — Manim-style — so a learner watches algebra move rather than hard-cut between frames. We found four distinct "glitches," and the fun part is that they all turned out to be the same bug wearing four different costumes.
Here's the story, the challenges, and the fixes. 🎬
Screen.Recording.2026-07-10.at.7.52.47.PM.mov
Screen.Recording.2026-07-10.at.8.01.25.PM.mov
The one principle behind all of it
Under the hood the morph is a FLIP animation keyed on
data-n:\htmlData{n=<id>}{…},data-n="<id>"on the DOM span,Every bug below is a glyph that should have had a stable identity — and didn't.
1. The integral sign snapped — #451
Challenge. On integration steps, the
∫sign would jump to its new position in a single frame while everything around it glided. The renderer emitted the sign as a bare glyph —\int …— with nodata-n. The FLIP engine keys off ids, so an untagged sign is invisible to it: it can't tween something it can't see, so the sign just teleports on re-render.Fix. Give each
∫its own stable id, derived from the integral node:\htmlData{n=<node>__int}{\int}. Because that id is threaded through the rebase like any other, a persisting integral now keeps its sign and glides; a new one fades in. Multi-variable integrals (∫∫) get one id per sign.The key idea we'd reuse everywhere: a stable id is never invented from scratch — it's derived from an id that's already stable (here, the integral node's), so it inherits the cross-step threading for free.
2. The derivative snapped — and the chain rule split it — #452
Challenge. Same disease on
d/dx: the twodglyphs and the variable in\frac{d}{dx}were bare, so a persisting derivative snapped. It was most visible on the chain rule step, whered/dtsplits intod/dh · dh/dtand the pieces teleported into place.Fix, with a twist. The two
ds are pure notation (no graph node), so they get a synthetic id scoped to the derivative. But the variabletis a real graph node — so its glyph should link to that node's id, not an invented one. A reviewer rightly pushed on this, and linking to the node had a bonus: thetind/dtnow resolves to its term on hover ("Time, the independent variable…").The subtlety: the same
xnode can be both the operand and the variable (d/dx x²). Naively emitting the node twice would produce a duplicatedata-n, which breaks the morph. So the variable glyph is occurrence-scoped to its derivative (t____deriv_2) — unique per spot, still resolves back to the term, never collides.3. Parentheses flew, and "log" flickered — #453
Two bugs in how functions render, both surfaced on the same step.
3a — a paren flew to the wrong place. The morph matched parentheses by glyph alone — every
(was interchangeable. So when\ln(v)appeared in a step (and its(happened to sort first in reading order),\sin's(from the previous step matched\ln's and flew across the equation, while\sin's real paren faded in.\sin/\ln/operator node). It fixed the flying — but regressed a different case, because a grouping paren's owner changes when the structure reorganizes ((-vₑ)moving from a fraction numerator to a factor).\sin's(wrapsgamma,\ln's wrapsv→ they can't be confused; and(-vₑ)wraps the same negation node on both sides of a rewrite → it glides. Content is stable across structural moves in a way owner isn't.3b — "log" flashed "lo" → "log". As a
\logappeared, you'd see "lo" pop in and the "g" fade in a beat later. It turns out KaTeX renders a function name as a.mopthat splits into a bare "lo" text node plus a separate "g" glyph span — and the engine only "saw" the real element (the "g"). Fix: wrap the whole name in one id (\htmlData{n=<node>__name}{\log}) so "log" is a single unit that fades in cleanly. Same for\sin,\cos,\ln.4. Symmetric equations: terms flew across the ∨ — #454
The trickiest one. The quadratic's two roots —
x = … + √D/2a ∨ x = … − √D/2a— split one equation into two structurally identical branches. The cross-step rebase matches nodes by content/signature, so it cheerfully paired a vanishing term from the single-equation side (the factored-away4a²denominator's4and·) with its twin in the other branch's4ac. The glyph flew clean across the∨.Fix — teach the matcher about the ∨. We tag each node with the disjunction/conjunction branch it lives in exclusively (computed structurally, so it's robust to the id asymmetry where the first branch reuses old ids), and then forbid any match whose two nodes are in different branches. A new branch only inherits from the same branch of the prior state; the first branch additionally seeds from a non-disjunction predecessor — so a single equation morphs into the first solution while the rest fade in. It's a no-op for every proof that has no
∨/∧.A nice detail from code review: the guard has to live in every phase of the matcher (top-down, bottom-up, ordered-align, recovery) — miss one and an isomorphic subtree can still sneak a cross-branch pairing through.
What we learned
\left(…\right)becomes a.minnerrow; stretchy delimiters are SVGs. A lot of the work was reading the actual rendered tree rather than assuming.Everything is verified end-to-end in the live renderer, and each fix ships with regression tests. If you want to see them in motion, open any built-in proof in
/renderproofand scrub the steps — the integral, the derivative's chain rule, thelogroots of a quadratic. It should move now. ✨PRs: #451 · #452 · #453 · #454
All reactions