31 July 2026

174 Commits In: Building an Interactive Math Education Tool with ClojureScript and KaTeX

Most side projects never get past the scaffold. This one has 174 commits and counting: a curriculum API, a web SPA, a native mobile app built with ClojureDart/Flutter, gated authentication with three login paths, a SQLite-backed store, and a production deployment behind nginx. It has been lived in.

I want to zoom into the part of the system that's easiest to relate to if you've ever tried to teach or learn math on a screen: getting the content to render correctly. Turns out that "just typeset the math" is a much longer road than it sounds.

The problem: curriculum content isn't clean LaTeX

Curriculum authors don't type LaTeX. They type things like Δ = b² - 4ac, or a worked example that mixes dollar amounts and math on the same line:

;; input, as a curriculum author actually wrote it
"\\text{First tier} = \\$5,000 \\times 0.04 = \\$200"

;; output: the whole \text/\times region gets wrapped as ONE math span,
;; the \$ signs are recognized as escaped currency, not delimiters
"$\\text{First tier} = \\$5,000 \\times 0.04 = \\$200$"

That's wrap-latex-portions earning its keep. Naive delimiter-detection would see the first \$, think "unescaped $, this must already be delimited," and ship the whole line raw to the renderer. Recognizing \$ as money rather than a math boundary is what keeps a commission calculation from breaking.

The uppercase-Greek case is smaller and bites the same way. Here's a discriminant test written the way a teacher would put it on a whiteboard:

;; input
"- Δ > 0: Two distinct real solutions"

;; output
"$- \\Delta > 0:$ Two distinct real solutions"

Δ becomes \Delta, since a bare Unicode glyph is something KaTeX won't reliably render, and the region-detector stops before the trailing colon instead of swallowing it into the math span. One character of boundary decided whether "Two distinct real solutions" rendered as English or got mangled into the formula.

Δ = b² − 4accurriculum text\Delta = b^2 − 4acnormalize-unicode-math$\Delta = b^2 − 4ac$wrap-latex-portionsΔ = b²−4acKaTeX · webΔ = b²−4acflutter_math_fork · mobile

One content pipeline, two renderers

The reason this normalization step matters so much: its output is the only thing the renderers ever see. On the web, that $...$-wrapped string goes to ClojureScript talking to KaTeX. On mobile, the identical string goes to ClojureDart driving flutter_math_fork inside Flutter. Two different rendering engines, two different platforms. Neither one ever touches raw curriculum text, Unicode Greek letters, or ambiguous dollar signs. All of that mess gets resolved once, upstream, in plain Clojure that runs on both platforms unchanged from a single .cljc file.

That's also why a bug where $$display$$ math shipped to mobile as a raw, unrendered string got fixed in the same normalizing function rather than in either renderer. $$...$$ collapses to a single $...$ before either KaTeX or flutter_math_fork sees it, because the mobile segmenter only understands single-dollar delimiters. Fix it once, upstream, and both platforms come out correct.

Visualizations: the same trick, one level up

Once you've normalized text once and rendered it twice, it's tempting to do the same for diagrams, and that's what happened, in a stronger form than I expected going in. A triangle, a Venn diagram, a probability tree, a weighted graph, a bearing diagram: each one is described as plain data, like this:

{:type :triangle :data {...}}

That gets dispatched through one function on the JVM:

(defn visual->svg [{:keys [type data]}]
  (case type
    :triangle        (svg-triangle data)
    :venn-diagram    (svg-venn-diagram data)
    :probability-tree (svg-probability-tree data)
    :weighted-graph  (svg-weighted-graph data)
    :bearing-diagram (svg-bearing-diagram data)
    ;; ... nine visual types total
    nil))

(defn visual->svg-string [visual]
  (some-> (visual->svg visual) hiccup->str))

The claim here goes further than "the same data renders on both platforms." visual->svg-string produces one literal SVG string, server-side. The web page drops that string straight into the DOM. The mobile app hands the identical string to flutter_svg's SvgPicture.string, which parses and rasterizes it natively. One rendered artifact, consumed by two different SVG engines.

{:type :triangle :data {…}}visual mapvisual->svg-stringJVM · case dispatchone svg string<svg>web pageSvgPicture.stringflutter_svg · mobilesame string, two renderers

Mobile visuals get one more layer of care that's easy to overlook. A scatter plot with null coordinates or a broken statistical chart shouldn't take down an entire lesson, and generated diagrams announce themselves to screen readers through Semantics instead of sitting silently invisible to anyone using one. That kind of detail only shows up once a tool gets used by students rather than demoed.

What happens after the math renders

Rendering math correctly is table stakes. The harder design problem is what happens once a student has to do something with it, and that starts with a curriculum corpus that doesn't agree with itself. The exercise-flattening code carries the scar tissue in its own comments:

;; :options (2,658 exercises) / :choices (51) -> [{:id "a" :label str} ...]
;; Bare-string options (1,622 of them) get positional string ids so an
;; integer index :correct matches via id->str.
;; 13 corpus MCs store the option TEXT as :correct, not an id, matched
;; by unique label instead; ambiguous labels = no match.

Three different shapes for "here are the answer choices," collapsed into one closed wire vocabulary (:choice, :numeric, :self-check, :practice) that the mobile app's pure exercise-checking logic can depend on. None of that normalization is visible to a student. It's why the "Show answer" gate and the XP tracking on top of it stay simple and hard to bypass by accident, instead of turning into a pile of special cases for every way a multiple-choice question happened to be authored.

The unglamorous 80%: auth and deploy

It would be easy to write a post only about KaTeX and SVGs and pretend the rest doesn't exist. A huge fraction of these 174 commits went to gated authentication with email/password plus Google and GitHub OAuth, a normalized identity model in SQLite, and a deployment to maths.b12n.net with nginx, systemd, and a gzip fix for large ClojureScript bundles behind a proxy. None of that is what draws you to a project, and all of it is what gets the rendering work above in front of a student instead of leaving it a local demo.

Takeaways

If you're building anything that mixes typeset math with interactivity, a few things from this project are worth stealing:

  • Normalize once, upstream of every renderer. Neither KaTeX nor flutter_math_fork ever sees raw curriculum text. By the time either renderer runs, the ambiguity is already gone.
  • "Single source" can mean the literal artifact. visual->svg-string hands both platforms one SVG string, which two different engines then parse.
  • Content from the field breaks your assumptions before it breaks your regex. 1,622 bare-string options, 13 mismatched :correct labels, a \$ that means money. That's what a working corpus looks like.
  • Never let a bad visual crash a lesson. Graceful degradation on rendering failures is a small amount of code that saves an entire session.

174 commits is a lot of iteration to get here, and it shows in the accumulation of small correctness fixes rather than any single dramatic feature. Those fixes only surface once content, students, and a live deployment arrive.

Tags: katex education interactive-learning clojurescript