setOpen(false)}
+ />
+
+ {/* panel */}
+
+ >
+ );
+}
diff --git a/package-lock.json b/package-lock.json
index 9d1ca27..b0d0fba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
+ "katex": "^0.16.47",
"lucide-react": "^1.33.0",
"media-chrome": "^4.19.2",
"motion": "^13.1.1",
diff --git a/package.json b/package.json
index de1f053..941ae65 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
+ "katex": "^0.16.47",
"lucide-react": "^1.33.0",
"media-chrome": "^4.19.2",
"motion": "^13.1.1",
diff --git a/src/agent/system-prompt.md b/src/agent/system-prompt.md
index e718943..cc621b7 100644
--- a/src/agent/system-prompt.md
+++ b/src/agent/system-prompt.md
@@ -182,6 +182,8 @@ Whenever math notation is involved — explanations, questions, quiz options and
If LaTeX can be used, it should be. Write $f(x) = x^2$, not `f(x) = x^2`. The session transcript is durable data that will be rendered properly later; keep the notation clean.
+One hazard: a bare `$` starts math. NEVER write currency as `$5` — write `\$5` (escaped) or `5 USD`. This applies to quiz questions, options, and explanations too.
+
## Session shape — one more thing
Every session you run produces the artifact from which the learner's knowledge graph is built: the quiz outcomes, the dependency map, the propositions you teach. Teach in clean, explicit units — each node stated crisply (e.g. "**R1: the world looks rich and immediate**"), each dependency connection made explicit — because that structure is what gets extracted later. Vague, hedged, mushy node statements can't be extracted; crisp ones can. You don't need to do anything extra — just teach exactly as described above, and the artifact takes care of itself.
diff --git a/src/core/question-core.ts b/src/core/question-core.ts
index da00eb4..f539d9b 100644
--- a/src/core/question-core.ts
+++ b/src/core/question-core.ts
@@ -115,10 +115,35 @@ export function normalizeOptions(
/** Fisher-Yates over a copy. correctAnswer is keyed by value, so display
* order can be shuffled safely before resolving indices. */
-export function shuffleOptions(options: QuizOption[]): QuizOption[] {
+// ─── options ────────────────────────────────────────────────────────────────
+
+/** mulberry32 seeded by a string — deterministic shuffle for hydration-safe rendering. */
+export function seededRandom(seed: string): () => number {
+ let h = 2166136261;
+ for (let i = 0; i < seed.length; i++) {
+ h ^= seed.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ let a = h >>> 0;
+ return () => {
+ a |= 0;
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Fisher–Yates with an injectable RNG. Pass `seededRandom(toolCallId)` in
+ * rendered components: Math.random() shuffles differently on server vs client
+ * and breaks hydration (React #418). A per-toolCallId seed keeps the order
+ * stable across reloads while still varying it per question.
+ */
+export function shuffleOptions(options: QuizOption[], rand: () => number = Math.random): QuizOption[] {
const out = [...options];
for (let i = out.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
+ const j = Math.floor(rand() * (i + 1));
const a = out[i]!;
const b = out[j]!;
out[i] = b;
diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx
index a29ac82..0462e16 100644
--- a/tests/render-cards.test.tsx
+++ b/tests/render-cards.test.tsx
@@ -15,6 +15,7 @@ import React from "react";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
+import { MessageResponse } from "../components/ai-elements/message.tsx";
import { QuizCard } from "../components/quiz-card.tsx";
import { AnsweredCard } from "../components/answered-card.tsx";
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
@@ -102,3 +103,62 @@ describe("render smoke tests", () => {
assert.match(html, /skipped/);
});
});
+
+describe("math rendering (streamdown + KaTeX)", () => {
+ it("renders INLINE math ($...$) — the LLM-native convention", () => {
+ const html = renderToStaticMarkup(
{"The map $f(x) = x^2$ is smooth."});
+ assert.ok(html.includes("katex"), `inline math should render as KaTeX, got: ${html.slice(0, 300)}`);
+ });
+
+ it("renders display math ($$ fenced on own lines — the prompt contract)", () => {
+ const html = renderToStaticMarkup(
{"$$\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n$$"});
+ assert.ok(html.includes("katex-display"), "fenced display math should render as display KaTeX");
+ });
+
+ it("one-line $$...$$ degrades to inline (micromark behavior — prompt forbids it)", () => {
+ // micromark treats a one-line $$...$$ inside a paragraph as 2-dollar
+ // inline mathText, not flow math. The system prompt instructs the fenced
+ // form, so the model never emits this; pin the behavior so a renderer
+ // swap can't silently change it.
+ const html = renderToStaticMarkup(
{"$$\\int_0^1 x^2\\,dx$$"});
+ assert.ok(html.includes("katex") && !html.includes("katex-display"), "one-liner should render inline");
+ });
+
+ it("does NOT eat escaped currency (\\$5 stays text — the prompt contract)", () => {
+ // micromark-extension-math@3.1 lacks pandoc's closing-digit rule, so bare
+ // "$5 and $10" would parse as math. The defense is at the source: the
+ // system prompt instructs the teacher to escape currency (\$5 / 5 USD).
+ const html = renderToStaticMarkup(
{"It costs \\$5 and \\$10 more."});
+ assert.ok(!html.includes("katex"), "escaped currency must not parse as math");
+ assert.match(html, /\$5/);
+ });
+
+ it("quiz cards render math in question, options, and explanation", () => {
+ const mathQuiz = {
+ question: "What is $\\frac{dy}{dx}$ of $y = x^2$?",
+ options: [{ label: "$2x$" }, { label: "$x^2$" }, { label: "$x$" }],
+ correctAnswer: "$2x$",
+ explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.",
+ };
+ const pendingHtml = renderToStaticMarkup(
+
{}} />,
+ );
+ assert.ok(pendingHtml.includes("katex"), "pending card: question/options math renders");
+
+ const options = normalizeOptions(mathQuiz.options);
+ const qd: QuestionData = {
+ id: "m1",
+ kind: "quiz",
+ question: mathQuiz.question,
+ mode: "single-select",
+ options,
+ correctIndices: resolveCorrect(coerceCorrectAnswer(mathQuiz.correctAnswer), options).indices,
+ explanation: mathQuiz.explanation,
+ createdAt: new Date().toISOString(),
+ };
+ const answeredHtml = renderToStaticMarkup(
+ ,
+ );
+ assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders");
+ });
+ });