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. That's not a stub. That's a system that's 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 real dollar amounts with real math:
;; 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 doing real work: 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, not a math boundary, is what keeps a commission calculation from breaking.
The uppercase-Greek case is smaller but just as real. Here's a discriminant test written the way a teacher would actually write it on a whiteboard:
;; input
"- Δ > 0: Two distinct real solutions"
;; output
"$- \\Delta > 0:$ Two distinct real solutions"
Δ becomes \Delta (not a bare Unicode glyph KaTeX won't reliably render), and the region-detector stops before the trailing colon instead of swallowing it into the math span. That's a one-character boundary that decided whether "Two distinct real solutions" rendered as English or got mangled into the formula.
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 exactly once, upstream, in code that's plain Clojure and runs on both platforms unchanged (it's a .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 ever see it, because the mobile segmenter only understands single-dollar delimiters. Fix it once, upstream, and both platforms are correct. That's the whole point of not letting either renderer touch raw input.
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 exactly 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))
This isn't "the same data renders on both platforms." It's stronger than that. 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 exact same string to flutter_svg's SvgPicture.string, which parses and rasterizes it natively. Not a shared data model that two separate renderers interpret. A single rendered artifact, consumed by two different SVG engines.
Mobile visuals get one more layer of care that's easy to overlook: a scatter plot with null coordinates or a bad statistical chart shouldn't take down an entire lesson, and generated diagrams announce themselves to screen readers via Semantics rather than being silently invisible to anyone using one. That's the kind of detail that only shows up once a tool is built to actually be used by students, not just demoed.
Interaction, not just typesetting
Rendering math correctly is table stakes; the harder design problem is what happens once a student is expected to do something with it. That problem 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 actually depend on. None of that normalization is visible to a student. It's the reason the "Show answer" gate and the XP tracking on top of it can stay simple, deliberately 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, but a huge fraction of these 174 commits are gated authentication (email/password, Google OAuth, GitHub OAuth), a normalized identity model in SQLite, and a real 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 the project, but all of it is what makes the rendering work above actually reach a student instead of staying 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_forkever sees raw curriculum text. By the time either renderer runs, the ambiguity is already gone. - "Single source" can mean the literal artifact, not just the data.
visual->svg-stringdoesn't hand two platforms the same description. It hands them the same SVG string, parsed by two different engines. - Real content will break your assumptions, not just your regex. 1,622 bare-string options, 13 mismatched
:correctlabels, a\$that means money: these aren't edge cases, they're what a real 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: not in any single dramatic feature, but in the accumulation of small correctness fixes that only reveal themselves once real content, real students, and real deployments enter the picture.