← Docs
Documentation

Math input

How a typed answer reaches a verdict: MathLive, MathJSON, the plain-text shim, and the mobile keypad.

How a learner's answer travels from their fingers to a verdict, and what has to be true at each step.

The short version: the answer is a plain string on the wire, and it always has been. The math editor changed where that string comes from and how good it is. It did not change the wire format, the endpoints or the graders. This document records the contract, the parts of MathJSON that are reserved for later and the places where the third-party libraries did not do what their names suggest.

  • The editor: web/next-app/components/math/
  • The compatibility shim: web/next-app/components/math/plainAnswer.ts
  • The proof it survives the graders: internal/grader/mathlive_compat_test.go

1. The wire format today

learner types  →  MathLive (LaTeX internally)
               →  MathAnswerInput holds the LaTeX, publishes plain text
               →  POST /api/study/answer   { concept_id, answer, elapsed, question }
               →  engine.GradeAnswer(conceptID, expected, answer)
               →  grader.Router / GradedGenerator / SymPy
               →  correct, streak, mastery, XP

answer is a string. It was a string before the editor existed and it is a string now. No endpoint field was added, no grading type was added and no mastery rule was touched. That was deliberate: ADR-005 makes Engine.GradeAnswer the single grading entry point, and keeping it untouched is what makes the editor safe to ship.

A host sees exactly this:

<MathAnswerInput value={answer} onChange={setAnswer} gradingType={…} conceptId={…} />

It stores a string and submits a string. MathInputValue is internal to the wrapper:

type MathInputValue = { latex: string; plainAnswer: string; mathJson?: unknown }

mathJson is reserved and always undefined today — see §3.

Which concepts get the editor

components/math/keyboards.ts is the only place this is decided, and the answer is now all of them: MATH_GRADING_TYPES holds every one of the nine grading_type values.

grading_typecontrolconcepts
numeric, symbolic, expression, polynomial, complexMathLive editor415
multiple_choice, tuple, ordering, comparisonMathLive editor, with ChoiceOptions above the field242

The editor used to be scoped to the five expression-valued types, on the reasoning that a third of the corpus answers with a tap target and a math editor there was a downgrade. That changed: multiple choice still renders ChoiceOptions, so a yes/no question stays a tap, and the keypad is there for the learner who would rather type. The counts above are the split by grading type, not a description of the control.

Note that SymbolPalette is still shipped and still renders in text mode; the editor change removed it from math mode, not from the app.

The server already sends grading_type on every question (ADR-009), so this needed no new schema and no per-question special cases.


2. Why a shim exists

MathLive's own plain-text output is not what Mathua's graders read. Recorded from 0.111.0 in Chromium (web/next-app/test/fixtures/mathlive-plain-text.json, regenerate with node scripts/record-mathlive-corpus.mjs):

Learner typesMathLive emitsGrader verdict
\frac{1}{2}(1)/(2)rejected — numericGrader parses only 1/2
\frac{2}{4}(2)/(4)rejected
4\frac{1}{10}4(1)/(10)rejected — reads as 41/10, not 4 1/10
7 \div 27 -: 2false miss — SymPy reads -: as subtraction
\sin(x)+\cos(x)sin (x)+cos (x)unreliable — SymPy's implicit-multiplication pass needs sin(

The fifth row is the one that already bit: the SymbolPalette inserted the × glyph, which grading/sympy_service.py rejects outright. Not a new class of bug — an existing one.

toPlainAnswer() removes four artifacts, in this order, and nothing else:

  1. -: → / (division)
  2. 4(1)/(10) → 4 1/10 (mixed number — must precede rule 3)
  3. (1)/(2) → 1/2
  4. drop a space between a name and its (… or ^…

It is a compatibility shim over a third-party serializer, not a parser. It never reinterprets an expression. Each rule has a test in test/plainAnswer.test.ts, and internal/grader/mathlive_compat_test.go re-implements it independently and asserts the two agree by running the real graders over the recording. That test is the evidence that the bridge is sound; it is not decoration.


3. MathJSON: reserved, not used

MathInputValue.mathJson is undefined and no call site reads it. It exists so the wire format can change later without touching the five answer hosts.

MathLive 0.111.0 cannot produce MathJSON. Verified against the shipped types and bundle:

  • OutputFormat is "ascii-math" | "latex" | "latex-expanded" | "latex-unstyled" | "latex-without-placeholders" | "typst" | "math-ml" | "plain-text" | "spoken" | …. There is no mathjson.
  • getValue('mathjson') no longer exists.
  • convertLatexToMathJson() is no longer exported. The only surviving MathJSON code in the 1.4 MB bundle is a private mathJsonToLatex used for the clipboard.

MathJSON is now Compute Engine's exclusive domain (@cortex-js/compute-engine, also MIT), and it is a dependency now — for the self-check, not for MathLive. So if a MathJSON wire format is ever added, the path is MathLive → LaTeX → ce.parse() → .json, not MathLive → MathJSON.

Importing the library is also all it takes to switch on MathLive's own MathJSON support: it resolves the engine through globalThis[Symbol.for("io.cortexjs.compute-engine")], which CE sets as a side effect of being imported, and logs an error when it is absent. test/equivalence.test.ts asserts that global exists.

MathLive still consumes MathJSON in two places — clipboard paste with format: "math-json", and as its default serializeToLatex — and it does not bundle the library that does it. It looks the engine up on a global symbol:

globalThis[Symbol.for("io.cortexjs.compute-engine")].ComputeEngine

and, finding nothing, logs "The CortexJS Compute Engine library is not available" and returns an empty string. So if @cortex-js/compute-engine is ever added, simply importing it is enough — MathLive picks it up through that global with no wiring. That is the cheapest possible first step, and it fixes MathLive's MathJSON paste path rather than changing any of ours.

The subset a Go grader would actually receive

Observed, not read from documentation. @cortex-js/[email protected] is installed, and this table is JSON.stringify(ce.parse(latex).json) run against it. That matters, because the library's documented canonical form does not match what it emits: the docs say Sqrt becomes ["Power", x, "Half"] and ["Power", x, 2] becomes ["Square", x]. In 0.147.0 neither happens — Sqrt and Root survive intact and x^2 stays a Power. A Go grader written against the documentation would fail on almost every input.

ExpressionMathJSON from 0.147.0Note
\frac{1}{2}["Rational", 1, 2]literal fractions become Rational, not Divide
\frac{x}{y}["Divide", "x", "y"]a symbolic denominator stays a Divide
\frac{1}{2}+\frac{1}{3}["Rational", 5, 6]parsed and evaluated on the way in
x^2["Power", "x", 2]not ["Square", "x"]
x^2+2x+1["Add", ["Power","x",2], ["Multiply",2,"x"], 1]
\sqrt{9}3a root of a number is just the number
\sqrt{x}["Sqrt", "x"]Sqrt survives canonicalisation
\sqrt[3]{x}["Root", "x", 3]
x-2["Add", "x", -2]Subtract is eliminated, as documented
-x["Negate", "x"]
2x+3["Add", ["Multiply", 2, "x"], 3]implicit multiplication is explicit here
\sin(x)["Sin", "x"]functions appear bare, not wrapped in Apply
\pi"Pi"a named constant, not a symbol
3.143.14high precision needs the {"num": "…"} object form

So the subset a Go grader would need is Add, Multiply, Power, Divide, Negate, Rational, Sqrt, Root, Sin/Cos/… , Pi and bare symbols — and it must tolerate that Subtract never arrives, Divide only survives with a symbolic denominator, and constant subexpressions arrive already evaluated.

The feedback contract

equivalence() is three-valued, and the naming is deliberate — a string union rather than true | false | undefined, because 'unknown' is an absence and undefined would be indistinguishable from a function that failed to return.

statemeaning
'equivalent'the two expressions are the same
'different'they are provably not
'unknown'Compute Engine could not decide, and we decline to guess

The actual results, so the states are not aspirational:

comparisonresult
x²+2x+1 vs (x+1)²equivalent
2^10 vs 1024different
\sqrt{x²} vs xunknown — correctly refuses; it is `
\sqrt{x²} vs \lvert x\rvertequivalent
2x+3 vs x²+2x+1unknown, not different

That last row is the one that matters. CE will not call two plainly unequal symbolic expressions different — it declines rather than guess — which is why 'unknown' is a first-class outcome and not an error path.

No state renders a verdict. Not 'equivalent', not 'different' and above all not 'unknown': nothing may render as "correct", "incorrect", "wrong", "try again" or "not yet". test/selfCheck.test.tsx asserts that for all three states, using a real pair that reaches each one.

This is not a missing feature, and the reason is the answer oracle. The client does not hold the expected answer — grading is server-side and answers are anchored there (ADR-005) — so there is nothing to compare against without shipping the answer to the browser. simplificationHint is what the UI uses instead: it reports what the learner's own expression simplifies to and cannot produce a verdict even in principle, because it never sees a second expression.

What Compute Engine does when asked to judge equality

Measured, because the answer decides whether an equivalence feature can exist at all (test/equivalence.test.ts):

ComparisonisEqualisIdenticallyEqualsimplify fallback
2x+3 vs 2*x+3truetrue—
(x+1)^2 vs x^2+2x+1undefinedtrue—
sin^2(x)+cos^2(x) vs 1undefinedundefined1 — resolves it
\sqrt{x^2} vs xundefinedundefined (correctly refuses)stays unequal
\sqrt{x^2} vs |x|undefinedtrue—
2x+3 vs 2x+4undefinedundefinedstays unequal
2^10 vs 1024truefalse—

Three things follow.

isEqual is unusable for grading. It returns undefined for the symbolic identities that matter — expanded versus factored, and every trig identity. It answers "do these have the same value", and two expressions with a free variable do not.

isIdenticallyEqual is the only one that can refuse, which is exactly what is wanted for sqrt(x^2) vs x: it declines rather than confirming a false identity, and it knows the right answer when asked about |x|. It also declines to call 2x+3 and 2x+4 different, so a hint built on it would have to say "I don't know" for plainly wrong input.

simplify is the tie-breaker, and it is what makes trig identities work — and it is also what produces |x| from sqrt(x^2), which is the single best argument for using a CAS here: the naive reading of sqrt(x^2) is x, and that is wrong for every negative x.

Which is why the shipped feature is a self-check — "that simplifies to X" about the learner's own expression — and not a comparison against an answer. The client does not have the expected answer, by design; see §4.

Is it worth building?

Not yet, on the evidence. getValue('plain-text') — MathLive's own public API, no Compute Engine — already round-trips fractions, exponents, roots, mixed numbers, implicit multiplication, subtraction, powers, ratios, remainder formats and symbolic variables through the existing graders, proven in mathlive_compat_test.go. The corpus test currently covers 12 of 34 recorded cases through the pure-Go path; the remaining 22 are the SymPy-backed types, which SymPy already handles with trigsimp, radsimp, expand, factor and random-substitution numeric checks.

A MathJSON layer would earn its place if it bought better verdicts. The one gap plain-text leaves is a constant expression the numeric grader cannot evaluate (10^6, 12 × 4); a MathJSON-aware numeric path would close it. It should not be added merely to have a structured representation.


The self-check, and what it costs

components/math/SelfCheck.tsx shows the learner what their own expression simplifies to: 1/2 + 1/3 → "that simplifies to 5/6", sqrt(x^2) → "that simplifies to |x|".

It is deliberately not an equivalence check against the expected answer. The client does not hold the expected answer, and shipping it to power a hint would turn the hint into an answer oracle and undo ADR-005. No code path lets the hint affect a submitted value, and test/selfCheck.test.tsx asserts that: the hint appears in the DOM while the value the host receives is untouched.

The cost is the part worth arguing about. Compute Engine is 788 kB gzipped — nearly four times MathLive. It is never in any route's initial JS, and never fetched unless a learner pauses mid-answer with arithmetic on screen. Measured:

  • Routes without math input: +0 kB.
  • Routes with the editor: +1 kB gzip (the hint's shell).
  • One 788 kB gzip download, on first use, expression-valued concepts only.

It is skipped entirely when navigator.connection.saveData is set, or the effective type is 2g/slow-2g. A one-line simplification is not worth someone's data allowance, and a feature that quietly spends it is worse than one that is visibly absent.

If that trade is wrong, SelfCheck.tsx is the only file to delete: nothing else in components/math/ imports it, and removing it costs no other behavior.

4. The fallback grading path

Two different fallbacks, often confused:

The grading fallback. grader.Result.Unavailable marks "the grader could not run" — a missing or broken SymPy runtime, a timeout, a busy semaphore. ADR-006 is explicit: this is never a student miss. No attempt, no streak reset, no weakness bump, no XP, and the client is told to retry rather than penalise.

The editor fallback. If MathLive's chunk fails to load — blocked asset, old browser, flaky connection — MathInput falls back to the plain <input>. An error boundary catches it, because a chunk that fails to load never mounts and no ref check would ever fire. This is not a lesser editor: the plain input grades correctly for every concept in the corpus, which is exactly what a learner with a blocked asset needs. It is the editor that always works.

Both are tested (test/mathInputFallback.test.tsx, and the existing grader tests).

The keycap latex/label trap

Two of Mathua's keypad keys were broken in all five layouts and no test noticed: backspace typed the literal text [backspace] into the answer field, and the zero key typed [0]. Both were declared like this:

const BACKSPACE: Keycap = { latex: '[backspace]', width: 2 }
const ZERO_WIDE: Keycap = { latex: '[0]', width: 2 }
Assumed: a keycap whose `latex` names a bracketed command performs that command.
Actual 0.111.0: `latex` is *inserted*. `executeKeycapCommand` resolves in order —
`command`, then `insert`, then `key`, then `latex` (run as an insert) and only
then `typedText(label)`. Separately, `normalizeKeycap` merges MathLive's own
`KEYCAP_SHORTCUTS` definition only when the keycap is a bare string, or carries a
`label` or `key` naming a known shortcut. `latex` is not one of those.
Difference: these keycaps matched no shortcut *and* fell through to the insert
branch, so they typed their own source text. `KEYCAP_SHORTCUTS["[backspace]"]`
carries `command: "performWithFeedback(deleteBackward)"` and
`KEYCAP_SHORTCUTS["[0]"]` carries `latex: "0"`; neither was consulted.
Fix: name the shortcut in `label`, which is what MathLive's own layouts do
(`{ label: "[backspace]", width: 1 }`). `test/keyboards.test.ts` now fails on any
bracketed token in a keycap's `latex`, which is the whole bug class.
Source: `node_modules/mathlive/mathlive.mjs` — `executeKeycapCommand`,
`normalizeKeycap`, `renderKeycap`, `KEYCAP_SHORTCUTS`.

A second trap sat behind it, and it is why the bug survived: renderKeycap only adds the MLK__keycap class when the keycap's class does not already contain separator, action, shift, fnbutton or bigfnbutton. So backspace, the caret arrows, the dismiss key and every separator render without it. Every selector in the suite used .MLK__keycap, including the 44px touch-target audit — so that audit measured only the keys that were already working and reported a clean pass over a keypad whose delete key typed its own name. e2e/helpers/answer.ts now exposes keycaps() (the row's visible direct children) and glyphKeycap(), and the audit asserts that it reached the delete and caret keys, so "no undersized keycaps" cannot quietly go back to meaning "no keycaps measured".

The loading window, measured

Between the answer UI appearing and MathLive being usable there is a window. It used to be filled with <div className="h-12" aria-hidden />: the right size, in the right place, and completely inert. A tap on it looked like it landed on an answer field and then did nothing.

Measured (make diagnose-answer equivalent: e2e/answer-diagnosis.spec.ts, everything on one performance.now() clock):

MathLive chunk latencywindow with no editor
0ms (cached)322ms
500ms611ms
1500ms1611ms
3000ms3116ms

And once the editor is up, nothing else is slow: input reaches React state in 2-7ms and the first tap on Check reaches the API and returns in 12-14ms, on desktop and both mobile profiles.

The loading state is now PlainAnswerInput — the same working plain input used when the chunk fails outright. So the learner can answer during the window rather than repeating themselves, and whatever they type is adopted when MathLive arrives.

Two things to be careful about if you re-measure this:

  • CDP network throttling does not reach the MathLive chunk here. It reported transferSize: 0 and completed in 39ms under a 4 Mbps profile while the main chunks correctly took 1100-1400ms — it is served from Chromium's memory cache. A first pass took that as "the network does not matter". Network.setCacheDisabled did not change it either.
  • Delay the chunk instead, and prove the delay applied. delayMathLiveChunk() finds its target by scanning the build output for MathLive's own markers, routes at context level, and counts its interceptions; every scenario reports delayApplied.

The 310ms floor is a floor for this harness, not a phone: desktop CPU, cached chunk. Network contribution is unmeasured and the cost of parsing a 794 kB decoded bundle on a mid-range phone is unknown. Both are larger.

Dismissal, and why Mathua no longer tries

Mathua used to hide the virtual keypad from a capture-phase pointerdown listener on document: any tap outside the field, and the keypad went away. It had to go.

MathLive raises the keyboard from exactly one place — a document focusin listener that calls show() after 300ms, gated on policy === 'auto' && hasEditableContent. There is no re-show path. So hiding the panel while the field still holds focus strands it: no focus transition means no focusin, so tapping the field again does nothing, and the learner has to keep tapping. On iOS Safari a tap on non-focusable content frequently does not blur the input, so this reproduced reliably on a phone while being invisible in Chromium.

MathLive already hides the keypad itself, from a focusout listener, when focus genuinely leaves the field. That is the behavior worth having, so the outside-tap listener was redundant and harmful. Mathua now hides the keypad in two places: when a focused field unmounts, since the Learn feed swaps cards under you, and when a field becomes read-only because its answer is being checked. The second is not an outside tap: focus has already gone to the Check button, and leaving the panel up put it over the feedback and the Next button on a phone, so the page looked frozen after an answer.

MathLiveField's pointerdown handler is the recovery path — it calls show(), and because show() only dispatches a message, calling it when the panel is already visible is harmless.

Show on a tap, never on focus

MathLiveField also used to call show() from the field's focus handler. That one was not harmless. Every show() or hide() makes MathLive dispatch virtual-keyboard-toggle, and the focused field answers it by blurring and refocusing its keyboard sink. The refocus re-entered the focus handler, which called show() again, and each blur armed MathLive's 300ms focusout timer, which hid the panel. Once a field already had focus when the learner tapped it — every question after the first, because each answer host autofocuses the field when it reveals one — the panel hid and reappeared about every 300ms for as long as the learner typed. On a Pixel 7 profile it was continuous; a learner reported the diagnostic going unresponsive after a few questions.

Focus now only installs the layout. On a touch device MathLive's own focusin listener still raises the panel, once, and pointerdown remains the explicit tap. A field whose focus arrives before it mounts — the plain input standing in while the chunk loads had it — takes focus on mount, so the caret is not lost to <body> when the editor arrives.

Not yet fixed

mathVirtualKeyboard.hide() had no measurable effect under Chromium when called directly from the page: visible stayed true and the panel stayed in the DOM, under both auto and manual. That is why the strand could not be reproduced in Playwright and why e2e/mobile-math.spec.ts asserts the call Mathua makes rather than the resulting visibility — spying on mathVirtualKeyboard.hide fails on the old listener and passes now. The recovery behavior itself still wants a real-device check.

Every question gets the editor

The editor was once scoped to the five expression-valued grading_types. That left 241 of 666 concepts — 212 multiple_choice, 26 tuple, 1 ordering, 2 comparison — typing mathematics into a plain <input>, because the answer hosts render a text field even when the question also offers tappable options.

Making it universal was measured, not assumed. The multiple-choice grader is strings.EqualFold(expected, answer), and MathLive reads bare adjacent letters as implicit multiplication:

expectedMathLive emittedgrades?
yes"y e s"✗
no"n o"✗
odd"o d d"✗
addition"a d d i t i o n"✗
pi/2"p i/2"✗
1/2"(1)/(2)"✓

yes is the single most common expected answer in the corpus (673) and no is second (171), so universal MathLive would have broken roughly 850 questions. Two rules close it, both covered by internal/grader/mathlive_compat_test.go against a recording of what the library emits:

  • fixImplicitLetterSpacing rejoins a word the keyboard split into letters. The guard is the hard part: 2pi r also contains a letter-space-letter, and collapsing it gives 2pir. So the run is extended left over its whole adjacent letter sequence and only collapsed when what precedes that is a letter or nothing.
  • fixFractionParens accepts a symbolic numerator, (pi)/(2) → pi/2, while staying narrow enough to leave (x + 1)/(y) alone.

Multiple choice still renders ChoiceOptions above the field, so yes/no remains a tap.

The keypad answers for the corpus

Two things were missing and both blocked real questions:

  • No comma in any layout. Every tuple and ordering answer is a list, so those 12 concepts had no way to enter their answer at all.
  • No < > ≤ ≥. dec.basics.compare and frac.ops.compare grade a comparison.

And a structural one, which was the expensive part: MathLive's toolbar navigates layouts, not layers. It emits data-layer="${layout.layers[0].id}" for each entry in kb.layouts, so a layout with three layers renders only the first and the other two are unreachable. A keycap carrying a layer property only gains a layer-switch class and does nothing when pressed. Layers 2+ had been unreachable from the start — the old operators and scientific-notation layer had no way in, which is the same class of bug as the keycap that typed its own name (ADR-029).

So each page is its own single-layer Layout, layoutsForMode returns them together, and kb.layouts = [...pages, 'alphabetic']. Every layer also needs a label, because the toolbar renders it and shows "untitled" without one. Letters come from MathLive's own alphabetic layout rather than a 26-key grid that could not hold the 44px floor at 320px.

5. Mobile setup

Real-device verification lives in mobile-device-checklist.md. Nothing in this document is a substitute for it: the automated suite uses emulated viewports, which cannot reproduce OS-keyboard suppression, iOS viewport units, safe-area insets, iOS focus zoom or real parse cost. The numbers below are floors.

What is actually configured, all verified against the pinned MathLive 0.111.0 rather than remembered from an earlier version.

Virtual keyboard policy. Not set. 0.111.0's default is 'auto', confirmed in the bundle's own defaults object, and auto is the behavior Mathua wants: raise the keypad on touch, leave it alone where there is a physical keyboard. The attribute exists as both mathVirtualKeyboardPolicy and math-virtual-keyboard-policy. It is left at the default rather than set redundantly, because setting it would assert something already true and would need re-asserting if the default ever changed.

Keyboard layouts. Five, chosen from the concept's domain by keyboards.ts: arithmetic, fractions, algebra, geometry, calculus. Installed on focus (kb.layouts = [layoutForMode(mode)]), because the keyboard is a page-wide singleton and every field would otherwise overwrite the last.

Keyboard container. MathLive's own. It positions the panel against the field, so it does not cover the editor. What it cannot do is shrink the page, so geometrychange is republished as one CSS custom property:

whatwhere
keypad height--mathua-keyboard-height on :root
keypad statedata-mathua-keyboard="open"|"closed" on :root

Stylesheets adapt to that instead of a hardcoded pixel height, which would be wrong on every device. app/globals.css pads <body> by the keypad's height while it is open, so the end of a page can be scrolled above the panel; at 320×568 the Check button is otherwise under it with no way to reach it. Safe-area is handled where it already was — ChromeToggle, ConceptGraphFlow, /history — and deliberately not added to the answer area, because MathLive's panel already sits above the home indicator and double-padding it would push the field away from the keypad.

Fonts. MathfieldElement._fontsDirectory defaults to './fonts/', and Mathua copies MathLive's webfonts there at build time (scripts/copy-mathlive-fonts.mjs). 300 kB of woff2. The property is left at its default because the default is already correct.

Sounds. Disabled, and this was a real bug:

MathfieldElement.soundsDirectory = null   // static; the instance accessor throws
MathfieldElement.keypressVibration = false

0.111.0 defaults _soundsDirectory to './sounds' and keypressSound to keypress-standard.wav, loaded with a fetch() on every keystroke. Mathua ships public/fonts and never shipped public/sounds, so each key requested a file that does not exist. Measured: two taps produced two requests for /…/chunks/sounds/keypress-standard.wav. soundsDirectory = null is the documented way to load no sounds; test/mobile-math.spec.ts asserts none are requested.

Compute Engine. Dynamically imported by SelfCheck, never in initial JS. 787 kB gzip, fetched once, on first use and skipped entirely when navigator.connection.saveData is set or the effective type is 2g/slow-3g.

Client-only loading. MathLiveField.tsx is the only file that imports mathlive, and it sits behind React.lazy inside a boundary. The boundary catches a chunk that fails to load, which no ref check can: a chunk that never mounts has no ref to check.

First Load JS, before and after

Measured from the built out/, gzip, summing the <script src> tags each prerendered route actually ships.

RouteBeforeAfterΔ
/learn436 kB437 kB+1
/review433 kB434 kB+1
/goals440 kB441 kB+1
/onboard438 kB438 kB0
/study (context-bound reference page, restored by ADR-054)429 kB429 kB0
/profile248 kB248 kB0
/history411 kB411 kB0
/graph252 kB252 kB0
/settings247 kB246 kB−1
/236 kB236 kB0
total3603 kB3605 kB+2

Lazily fetched, never in the above:

chunkgzipwhat
c48e1edc…787 kBCompute Engine — first simplification only
428ea25f…215 kBMathLive — on any expression-valued question

The MathLive chunk is 794 kB decoded, which is why the loading window is ~310ms even with the bytes already cached: it is parse and evaluate, not download.

6. API surprises

Everything here cost time to find and is worth writing down.

math-mode-space is ignored; set the property. This is the spacebar. MathLive's own alphabetic layout — reachable from the keypad toolbar on every question — ships

{ label: " ", width: 1.5 }

A label and a width, and no command, insert, latex or key. executeKeycapCommand resolves a keycap as command → insert → key → latex → typedText(label), so with none of the first four set it falls through to typedText(" "). That reaches the [Space] keystroke handler, which does:

if (mathfield.options.mathModeSpace) { insert(mathModeSpace) }
else                               { moveAfterParent() }

mathModeSpace defaults to "". So the spacebar moved the caret and inserted nothing — an upstream defect, not a Mathua regression. Three generator answer formats need a real space: Q R (remainder), a sqrt(b) and P=[[...]] D=[[...]].

The payload is \,, not a literal space, because TeX discards literal spaces in math mode. \, is the proven round-trip value: the recorded corpus holds expression 5 R 3 <- 5\ R\ 3, which comes back as plain text 5 R 3 and grades.

Set it as a property, not the documented attribute. Measured on 0.111.0 with React 18: the attribute is present in the DOM and placeholder and class are both honoured from it, but mathModeSpace stays "" and the spacebar stays inert. el.mathModeSpace = '\,' and el.setOptions({ mathModeSpace: '\,' }) both work. An attribute that is silently ignored is worse than none — it reads as configuration and is not.

Two more traps on the way, both the ADR-029 class again. A keycap of { label: ' ' } does work (it maps to [Space]) but renders as a blank face, which is half of why the key read as broken. The obvious fix — a visible label: '␣' — types the glyph ␣ into the field, because with no payload beside the label it falls through to typedText('␣'). The payload has to be stated alongside: { label: '␣', latex: '\,' }.

The keypad's width budget is in keycap-widths, and it depends on the viewport. MathLive does not stretch keys to fill its panel: measured on a Pixel 7 (400px panel), every keycap renders 45px and a width: 2 keycap renders 93px, whatever the row holds. So the binding constraint is the row's total units, and the ceiling is the point at which units × 45 stops fitting:

panel5u6u7u8u9u10u
400px (412px phone)454545454237
308px (320px phone)45454136——

Eight units is the design target and is what test/keyboards.test.ts asserts for all five modes. At 320px the ceiling is six units, and thirteen rows across four modes are already over it — so the 44px floor holds at 412px and does not hold at 320px. The e2e touch-target audit passes at 320px only because it measures whichever layout the default question selects, ARITHMETIC, the one mode whose rows all fit. Closing that means splitting the eight-keycap rows across modes, which is a redesign rather than a fix.

geometrychange carries no detail. The 0.111.0 docs say evt.detail.boundingRect. The library dispatches new Event('geometrychange') — a plain Event, no detail at all — from a ResizeObserver and from stateChanged(). A handler written to the documented shape reads undefined, computes a height of zero, and reports the keypad as closed for the rest of the session: the exact failure the listener exists to prevent. Read kb.boundingRect instead.

soundsDirectory is static, and the instance accessor throws. Setting field.soundsDirectory throws Error("Use MathfieldElement.soundsDirectory instead"), so it has to be the class. The default is './sounds', and sounds are fetched per keystroke.

The keycap latex is an insert, not a command. See "The keycap latex/label trap" above — backspace and the zero key typed their own source text in all five layouts.

getValue('plain-text') returns "" in jsdom, always — so the component suite stubs the library out (test/stubs/mathlive.ts, aliased in vitest.config.mts) and the real behavior is verified in Playwright. MathLive's node export condition also resolves to the SSR build under vitest, which never registers <math-field> at all.

next/dynamic({ ssr: false }) hangs under vitest. It resolves through Next's build-time chunk loader, which does not exist outside the Next build, so the loading state never resolves. React.lazy + Suspense gives the same production result and is testable.

React 18 does not map className on custom elements. It forwards props verbatim, so <math-field className="…"> writes the attribute classname, which no CSS selector matches. It must be class.

A bare-string Vite alias is a prefix match. Aliasing mathlive also rewrote mathlive/static.css, and the lazy import rejected. Use an exact RegExp — and put the alias under test.alias, because vitest externalises node_modules deps before resolve.alias applies.

mathlive/static.css is the import path, not the filename. The exports map exposes "./static.css", not "./mathlive-static.css".

MathLive loads its own webfonts from <origin>/fonts at runtime. There is no server to serve them under output: 'export', so scripts/copy-mathlive-fonts.mjs copies them from node_modules on predev/prebuild. Without them every fraction renders in a fallback face.

Keycap width accepts only 0.5 | 1 | 1.5 | 2 | 5. Anything else is a type error at best and silently ignored at worst.

Keycap width is derived from 10cqw — 10% of the keypad panel — which lands around 37px on a 412px phone. --keycap-width overrides it. Height alone was never the binding constraint for the 44px touch-target floor; this was.

Typing the √ glyph produces \surd, not \sqrt{9}. \surd is an ordinary symbol with the radicand outside it. This is why the keypad's √ key inserts \sqrt{#0} — and why the SymbolPalette's √ could never have worked.

preventDefault() on Enter is unnecessary. MathLive treats Enter as "commit the expression". Leave it alone.

A backspace immediately after a typed fraction does nothing in 0.111.0 — the caret sits after the fraction's trailing placeholder rather than adjacent to a removable atom. A second backspace removes it. Asserted as observed, not as expected.

Playwright's atomic locator.click() does not focus MathLive; mouse.down() and mouse.up() separated by a delay does. And typing needs ~40 ms per key, or MathLive's asynchronous parser drops characters — a harness artefact, not a product bug.


7. Where the keyboard choice lives

keyboards.ts maps grading_type + domain → MathKeyboardMode; layouts.ts holds the five keycap grids as plain data. No React component branches on a domain.

grading_type ──► editor   (math | text)          keyboards.ts
concept_id ──► domain ────► MathKeyboardMode ──► layout   layouts.ts

Unmapped domains fall back to algebra, the most general of the five: falling back to arithmetic would hide the letters a linear-algebra or number-theory question needs, which is the worse failure.

Structural keys insert structures. The fraction key inserts \frac{#@}{#0} — with a navigable placeholder — not /. A keycap that inserted the character would look identical in a screenshot and behave nothing like a fraction. test/keyboards.test.ts asserts this for every mode, and e2e/math-input.spec.ts asserts it against the real library.

8. The LaTeX corpus check

web/next-app/scripts/normalize-latex.mjs runs every math span in the lesson corpus through Compute Engine and reports the malformed ones (make latex-normalize).

The corpus gate (test/latexCorpus.test.ts) already proves KaTeX can render every lesson, but that is a weak signal: KaTeX is forgiving by design and will render a malformed fraction by guessing. This is the strict counterpart.

Four things about it are not obvious, and each was learned the hard way:

The spans come from cmd/latexdump -spans, not from a regex. latexnorm.Scan is the only thing in the tree that knows a dollar can be a delimiter or a currency amount, and it runs over the canonicalized body — the text the API actually serves. A regex would report every "$5 and up" in the corpus as a syntax error.

ce.parse does not throw. It is total: a LaTeX syntax error comes back as a tree containing an ["Error", reason, …] node. A try/catch reports a corpus with thousands of broken spans as perfectly clean, which is worse than no report because it is a confident lie. Failure is read off the returned box.

A parse failure is not automatically a defect. CE is a numeric/symbolic CAS; this corpus is largely abstract algebra, group theory and topology. CE reports U \times F as incompatible-type: number, function — having parsed the syntax perfectly and then declined to type a product of two sets — and does the same for \mathbb{R}^2 \setminus \{0\}. Filing those as "unparseable" buries the real defects under ~1,600 spans of noise and gets the report switched off. Reasons are bucketed by what CE objected to: syntax (a real defect: unclosed delimiter, stray operator, stray token), not-numeric (understood, refused to type it), ce-gap (a command CE has no rule for, which cannot be told from valid-but-unsupported without a human).

Every span is accounted for, and the buckets reconcile. The summary asserts clean + malformed + unchecked == spans and exits 2 if not, because that check is what caught the first two versions of this script reporting "20,549 of 46,872" while happily reporting success. It also states checked_pct, because a report that quietly covers 44% of its input is not a clean bill of health. make validate does not include it and CI runs it with continue-on-error: true — the corpus has pre-existing defects, so a hard gate would block main on content rather than on a regression.

Current state of the corpus, after cmd/latexfix repaired what was mechanical:

spans
checked (parsed cleanly)37,657
malformed228 (137 unique, in 60 files)
unchecked: not an expression (environments, prose-in-math, arrows)6,480
unchecked: valid but outside CE's numeric type system773
unchecked: command or operand CE does not implement1,650
engine crashes (CE overflowed its own recursion)0
total46,821 across 577 files

cmd/latexfix took it from 517. Three classes were mechanical and are now fixed:

  • Unicode apostrophes in derivatives (162 → 1). f’(x) is a typo. ‘a \text{is less than}\ b’ is a quotation mark, and rewriting it to ' makes LaTeX render primes — a silent change to what the lesson teaches. The rule only fires on an attached apostrophe, and never on an opening curly quote.
  • Scrape artifacts: 42✓ correctness ticks (68) and HTML entities leaked into math (&gt;, &#39;) (32).
  • Doubled delimiters (14,936 \(, plus the display-math \[). The Algebrica scrape wrote \(, which latexnorm reads as an escaped backslash followed by a paren — so every Algebrica formula sat in a prose region and no region-based tool could see it. The canonicalizer is what turned it into $ … $, which is why the checker found defects the fixer could not reach. Un-escaping is provably free: canonicalize emits byte-identical output for \(x\) and \(x\), and that invariant is a test.

Two findings worth keeping:

  • \[ needs a discriminator. A row break in an align environment is \[2pt] — two backslashes then a bracket holding a length, closed by a single ]. So \] is unambiguously a display closer, while \[ is a display opener only when not followed by a length. The corpus agrees: 4,496 \] against ~4,500 non-row-break \[, and 1,157 genuine row breaks left alone.
  • Removing a ✓ can create a defect. $42✓$ → $42$ is a bare numeral set as math, which the canonicalizer escapes to \$42\$ — the learner sees literal dollar signs (ADR-016). So a repaired span that becomes a bare numeral loses its delimiters too: $42✓$ → 42. The old form was itself broken; the learner was seeing \$1✓\$.

Of the 517, the largest single cause is a Unicode apostrophe where LaTeX wants ASCII: f’(x) = 18x - 4 is 162 of them, from the scrape. KaTeX renders it anyway, which is exactly why no existing gate caught it. The rest are unbalanced delimiters, stray & outside an aligned environment, and one prose paragraph that latexnorm.Scan classified as math.

Source: docs/math-input.md