Blog

All posts
John Damask · 2026-05-25
devlogfrontendarchitecturesecurityfeatures

When Now I Get It! turns a scientific paper into a web page, the result is usually great, but sometimes not perfect. Maybe a heading reads awkwardly, a font color makes text hard to read, or a math formula is off. Until recently the only fix was to start over and recreate the Translation. That's not only a bad user experience in terms of time and money, but there's also no guarantee that the re-generated version will be better.

The solution was to build a simple inline editor for text and KaTeX formulas. Page owners will see a little pencil icon in the corner of each page. Clicking it switches to edit mode. The save overwrites the same file the URL already serves, so links never move. No new charge!

Like most things with Agentic Engineering, it was easy to get the first version running but took real work to build a robust editor.

An intuitive editor

When the owner of a page views it, they see an edit icon. Clicking it reveals all editable components on the page and a simple editor bar. Users can change text, fonts, font color and highlight color. editor

To support equation editing I had to modify my LLM post-processing script to wrap every KaTeX fragment in a tagged sentinel span with a stable ID. This makes it so that when an equation is selected, a popover is shown with the KaTeX. This wasn't trivial - it was tricky getting everything to line up so that the editing experience is as simple as possible. Unedited math survives a surrounding text edit via an "opaque token" round-trip: when the editor serializes a paragraph, each math span collapses to a single placeholder, and the server re-hydrates it from the original bytes -- so the rendered formula never gets baked in, and text and math edits compose in a single save. katex-editor

"Save the live DOM" isn't a good idea

Early tests and a code review showed I had an architectural flaw. I had been having the editor save a snapshot of the live page but some of the richer pages render charts and math into the DOM after load, with Chart.js, D3, and KaTeX. By snapshotting the live DOM, I baked in all that generated SVG into the saved file. Next load, the scripts would run again and draw on top of the baked-in copy. On one test paper, a 49KB source became 780KB in a single save -- 16x -- and would keep growing until it hit the size cap and saves failed permanently.

The fix was a genuine rewrite. Instead of photographing the DOM, the editor now models each editable paragraph as a list of typed "runs" -- text plus optional emphasis, font, color, or link. The contenteditable region is just a view; the runs are the truth; on save the editor ships the runs, and the server rebuilds HTML from those typed inputs and splices them into the original published bytes with a parser-free byte scanner. (The friend's other gift: empirically proving that every Python HTML parser we tried mutated Claude's bytes -- lowercasing SVG viewBox to viewbox, reordering attributes -- so "parse and re-serialize" was never going to be byte-stable. Splice, don't parse.)

Because the wire format carries typed runs and never HTML strings, a whole catalog of bugs became structurally impossible: HTML injection (no HTML field), CSS injection (fonts are an enum, colors are hex-validated), and the bake bug itself (chart output literally cannot ride on the wire).

The same review also caught a subtle XSS attack vector where a smuggled script could hijack a session. Unlikely but not impossible. Fixed.

Nine bugs that no unit test could catch

My test suite reported 47 greens and no reds so I was ready to make a PR. But manual tests over the next hour surfaced nine bugs -- every one in the gap between "the server contract is correct" and "a person clicks a button and the right thing happens". These included:

...and five more of the same shape. None were catchable by the tests I had, because they live in browser behavior: focus stealing, cache policies, modal dialogs, the actual pixels on screen. I had to chastise my coding agent to prioritize user experience and focus on the basics as well as the details. Things like: Does the edit button appear? Does Save save? Does Cancel cancel?

Trust nothing, check everything

Every edit needs to be checked for potential hacks. There are plenty of ways to exploit a system that is too trusting. My initial approach was to use the Bleach HTML sanitizing library as a server-side sanitizer. This ran the changes through an allowlist library, stripped anything dangerous, and wrote the result. This immediately broke pages: charts vanished, math stopped rendering, the console filled with syntax errors.

The library was a fragment sanitizer being fed whole documents. It quietly dropped the doctype (which threw browsers into quirks mode, and the math renderer refuses to run in quirks mode) and HTML-escaped the contents of <script> blocks, so if (a && b) became invalid JavaScript. Every inline script on the page died. A 56KB page ballooned to 198KB over three saves as the sanitizer re-serialized its own output.

My fix was to drop Bleach and build a sanitizer from scratch and add a design principle, an anti-pattern, to avoid adding third party libraries that could implicitly create a competing governance system.

This was a good solution where I could stop untrusted content at the point where it enters the document, not where it leaves. In a contenteditable region, typing is already safe -- a typed <script> becomes literal text, not an executable tag. The editor also intercepts paste/drop and inserts only plain text. The save endpoint became a dumb writer: bytes in, bytes out. About 50 lines of editor JavaScript replaced 150 lines of server sanitization.

Almost perfect

This is a nearly perfect solution to the natural non-determinism of LLMs. It helps my customers by removing the need to spend additional credits recreating translations just because of small errors, and it saves me time by reducing the number of support requests I receive.