UI iteration 1: app shell + full LaTeX pipeline

Layout:
- Home = knowledge-graph placeholder (main view) with new-session input
  docked below; session list moved out of home into a hamburger drawer
- SessionDrawer: slide-over session navigation (backdrop/ESC close, active
  highlight, fetches /api/chats on open) on both home and chat headers
- Chat header loses the back button — the drawer is the navigation

Math (inline $ was silently not rendering):
- @streamdown/math defaults singleDollarTextMath:false — now
  createMathPlugin({ singleDollarTextMath: true }) in message + reasoning
- katex.min.css imported in layout (streamdown does not inject plugin
  styles; display math only looked right via browser MathML fallback)
- QuizCard/AnsweredCard route question/options/explanation through
  MessageResponse; user chat bubbles render markdown+math too
- Currency hazard: micromark-extension-math@3.1 lacks pandoc's
  closing-digit rule, so bare '$5 and $10' parses as math — defense is at
  the source: system prompt now instructs escaped \$5 / 'USD' for money

Hydration fix (React #418):
- QuizCard shuffled options with Math.random() — server/client orders
  diverged. shuffleOptions now takes an injectable RNG; QuizCard passes
  seededRandom(toolCallId) (mulberry32/FNV-1a in question-core). Same
  question → same stable order across reloads; still varies per question.

Tests: 53 pass (5 new math-rendering pins incl. fenced-vs-one-line $$
behavior and the escaped-currency contract). tsc + next build clean.
Verified live in browser: inline/display math in text + cards, drawer
open/navigate/active-highlight/ESC, no console errors, no font 404s.
This commit is contained in:
Amos
2026-08-22 03:23:11 +03:00
parent a72c902d30
commit 46d71380fa
14 changed files with 302 additions and 72 deletions
+1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "katex/dist/katex.min.css";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
+1 -4
View File
@@ -1,8 +1,5 @@
import { listChats } from "@/src/store/chats";
import { Home } from "@/components/home"; import { Home } from "@/components/home";
export const dynamic = "force-dynamic";
export default function HomePage() { export default function HomePage() {
return <Home chats={listChats()} />; return <Home />;
} }
+9 -2
View File
@@ -14,7 +14,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk"; import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code"; import { code } from "@streamdown/code";
import { math } from "@streamdown/math"; import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid"; import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
@@ -324,7 +324,14 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
// Type-only version skew: @streamdown/code bundles shiki@3 types while // Type-only version skew: @streamdown/code bundles shiki@3 types while
// streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its // streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its
// own highlighter); the cast papers over the .d.ts mismatch. // own highlighter); the cast papers over the .d.ts mismatch.
const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig; // singleDollarTextMath: the teacher prompt instructs $...$ inline math (the
// LLM-native convention); remark-math's default here is false.
const streamdownPlugins = {
cjk,
code,
math: createMathPlugin({ singleDollarTextMath: true }),
mermaid,
} as unknown as PluginConfig;
export const MessageResponse = memo( export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => ( ({ className, ...props }: MessageResponseProps) => (
+8 -3
View File
@@ -9,7 +9,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk"; import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code"; import { code } from "@streamdown/code";
import { math } from "@streamdown/math"; import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid"; import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react"; import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
@@ -204,8 +204,13 @@ export type ReasoningContentProps = ComponentProps<
children: string; children: string;
}; };
// See message.tsx — same streamdown plugin type skew. // See message.tsx — same streamdown plugin type skew + math config.
const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig; const streamdownPlugins = {
cjk,
code,
math: createMathPlugin({ singleDollarTextMath: true }),
mermaid,
} as unknown as PluginConfig;
export const ReasoningContent = memo( export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => ( ({ className, children, ...props }: ReasoningContentProps) => (
+8 -3
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core/question-core"; import type { QuestionAnswerData } from "@/src/core/question-core";
/** /**
@@ -36,7 +37,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">incorrect</span> <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">incorrect</span>
))} ))}
</div> </div>
<div className="mb-3 font-medium">{answer.questionText}</div> <div className="mb-3 font-medium">
<MessageResponse>{answer.questionText}</MessageResponse>
</div>
{answer.options && answer.options.length > 0 && ( {answer.options && answer.options.length > 0 && (
<div className="flex flex-col gap-1.5 text-sm"> <div className="flex flex-col gap-1.5 text-sm">
@@ -61,7 +64,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
{!sel && key && "✓"} {!sel && key && "✓"}
</span> </span>
)} )}
<span className={cn(!sel && "text-muted-foreground")}>{opt.label}</span> <span className={cn(!sel && "text-muted-foreground")}>
<MessageResponse>{opt.label}</MessageResponse>
</span>
</div> </div>
); );
})} })}
@@ -75,7 +80,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
{isQuiz && answer.explanation && ( {isQuiz && answer.explanation && (
<div className="mt-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground"> <div className="mt-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Why: </span> <span className="font-medium text-foreground">Why: </span>
{answer.explanation} <MessageResponse className="inline">{answer.explanation}</MessageResponse>
</div> </div>
)} )}
{answer.note && <div className="mt-2 text-xs text-muted-foreground">Note: {answer.note}</div>} {answer.note && <div className="mt-2 text-xs text-muted-foreground">Note: {answer.note}</div>}
+6 -7
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { import {
@@ -26,6 +25,7 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { QuizCard } from "@/components/quiz-card"; import { QuizCard } from "@/components/quiz-card";
import { AnsweredCard } from "@/components/answered-card"; import { AnsweredCard } from "@/components/answered-card";
import { SessionDrawer } from "@/components/session-drawer";
import type { TeacherUIMessage } from "@/src/agent/ui-message"; import type { TeacherUIMessage } from "@/src/agent/ui-message";
import type { QuestionAnswerData } from "@/src/core/question-core"; import type { QuestionAnswerData } from "@/src/core/question-core";
import { findPendingToolCall } from "@/src/core/pending"; import { findPendingToolCall } from "@/src/core/pending";
@@ -46,7 +46,6 @@ export function Chat({
initialMessages: TeacherUIMessage[]; initialMessages: TeacherUIMessage[];
initialPrompt?: string; initialPrompt?: string;
}) { }) {
const router = useRouter();
const { messages, sendMessage, addToolOutput, status, error, stop } = const { messages, sendMessage, addToolOutput, status, error, stop } =
useChat<TeacherUIMessage>({ useChat<TeacherUIMessage>({
id: chatId, id: chatId,
@@ -80,10 +79,8 @@ export function Chat({
return ( return (
<div className="flex h-dvh flex-col"> <div className="flex h-dvh flex-col">
<header className="flex items-center gap-3 border-b px-4 py-3"> <header className="flex items-center gap-3 border-b px-3 py-2">
<Button variant="ghost" size="sm" onClick={() => router.push("/")}> <SessionDrawer />
← Sessions
</Button>
<div className="min-w-0 flex-1 truncate font-medium">{title}</div> <div className="min-w-0 flex-1 truncate font-medium">{title}</div>
{busy && ( {busy && (
<Button variant="outline" size="sm" onClick={() => stop()}> <Button variant="outline" size="sm" onClick={() => stop()}>
@@ -105,7 +102,9 @@ export function Chat({
<Message key={m.id} from={m.role}> <Message key={m.id} from={m.role}>
<MessageContent> <MessageContent>
{m.role === "user" ? ( {m.role === "user" ? (
<div className="whitespace-pre-wrap">{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}</div> <MessageResponse>
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
</MessageResponse>
) : ( ) : (
m.parts.map((part, pi) => { m.parts.map((part, pi) => {
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
+29 -29
View File
@@ -2,11 +2,17 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { WaypointsIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { ChatSummary } from "@/src/store/chats"; import { SessionDrawer } from "@/components/session-drawer";
export function Home({ chats }: { chats: ChatSummary[] }) { /**
* Home — the knowledge graph is the main view (placeholder until the graph
* pipeline lands), with the new-session input docked below it. Sessions live
* in the hamburger drawer.
*/
export function Home() {
const router = useRouter(); const router = useRouter();
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -29,14 +35,27 @@ export function Home({ chats }: { chats: ChatSummary[] }) {
}; };
return ( return (
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col px-4 py-10"> <div className="flex h-dvh flex-col">
<h1 className="mb-1 text-2xl font-semibold">Learning Harness</h1> <header className="flex items-center gap-2 px-3 py-2">
<p className="mb-8 text-sm text-muted-foreground"> <SessionDrawer />
A teacher with a memory. Start a session on anything you want to understand. </header>
</p>
{/* Graph — the main view. Placeholder until the knowledge graph lands. */}
<main className="flex flex-1 items-center justify-center px-4">
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
<p className="max-w-sm text-sm">
Every session you complete will map what you know — and where the edge of your
understanding is — right here.
</p>
</div>
</main>
{/* New session input */}
<div className="px-4 pb-6">
<form <form
className="mb-10 flex gap-2" className="mx-auto flex w-full max-w-2xl gap-2"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
void start(); void start();
@@ -47,32 +66,13 @@ export function Home({ chats }: { chats: ChatSummary[] }) {
onChange={(e) => setTopic(e.target.value)} onChange={(e) => setTopic(e.target.value)}
placeholder="What do you want to learn?" placeholder="What do you want to learn?"
autoFocus autoFocus
className="h-11"
/> />
<Button type="submit" disabled={!topic.trim() || busy}> <Button type="submit" disabled={!topic.trim() || busy} className="h-11">
{busy ? "Starting…" : "Start"} {busy ? "Starting…" : "Start"}
</Button> </Button>
</form> </form>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-muted-foreground">Sessions</h2>
{chats.length === 0 ? (
<p className="text-sm text-muted-foreground">No sessions yet.</p>
) : (
<div className="flex flex-col gap-2">
{chats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => router.push(`/chat/${c.id}`)}
className="rounded-lg border px-4 py-3 text-left transition-colors hover:bg-accent"
>
<div className="truncate font-medium">{c.title}</div>
<div className="text-xs text-muted-foreground">
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleString()}
</div> </div>
</button>
))}
</div> </div>
)}
</main>
); );
} }
+15 -6
View File
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AskToolInput, QuizToolInput } from "@/src/agent/tools"; import type { AskToolInput, QuizToolInput } from "@/src/agent/tools";
import { import {
@@ -9,6 +10,7 @@ import {
coerceCorrectAnswer, coerceCorrectAnswer,
normalizeOptions, normalizeOptions,
resolveCorrect, resolveCorrect,
seededRandom,
shuffleOptions, shuffleOptions,
type AnswerRequest, type AnswerRequest,
type QuestionAnswerData, type QuestionAnswerData,
@@ -37,8 +39,9 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const); const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
// Build the canonical QuestionData once per question. Quiz options are // Build the canonical QuestionData once per question. Quiz options are
// shuffled so position never leaks correctness; the display order is // deterministically shuffled (seeded by toolCallId) so position never leaks
// snapshotted into the answer record at submit time. // correctness AND server/client render the same order (hydration-safe);
// the display order is snapshotted into the answer record at submit time.
const qd = useMemo<QuestionData>(() => { const qd = useMemo<QuestionData>(() => {
const quizInput = kind === "quiz" ? (input as QuizToolInput) : null; const quizInput = kind === "quiz" ? (input as QuizToolInput) : null;
const askInput = kind === "ask" ? (input as AskToolInput) : null; const askInput = kind === "ask" ? (input as AskToolInput) : null;
@@ -50,7 +53,7 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
: "single-select" : "single-select"
: (askInput?.mode ?? (askInput?.options?.length ? (multi ? "multi-select" : "single-select") : "text")); : (askInput?.mode ?? (askInput?.options?.length ? (multi ? "multi-select" : "single-select") : "text"));
const options = normalizeOptions((quizInput?.options ?? askInput?.options ?? []) as { label: string; value?: string }[]); const options = normalizeOptions((quizInput?.options ?? askInput?.options ?? []) as { label: string; value?: string }[]);
const ordered = kind === "quiz" && options.length > 0 ? shuffleOptions(options) : options; const ordered = kind === "quiz" && options.length > 0 ? shuffleOptions(options, seededRandom(toolCallId)) : options;
const q: QuestionData = { const q: QuestionData = {
id: toolCallId, id: toolCallId,
kind, kind,
@@ -125,8 +128,14 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
{kind === "quiz" ? "Quiz" : "Question"} {kind === "quiz" ? "Quiz" : "Question"}
{qd.mode === "multi-select" && " · select all that apply"} {qd.mode === "multi-select" && " · select all that apply"}
</div> </div>
<div className="mb-3 font-medium">{qd.question}</div> <div className="mb-3 font-medium">
{qd.context && <div className="mb-3 text-sm text-muted-foreground">{qd.context}</div>} <MessageResponse>{qd.question}</MessageResponse>
</div>
{qd.context && (
<div className="mb-3 text-sm text-muted-foreground">
<MessageResponse>{qd.context}</MessageResponse>
</div>
)}
{qd.mode !== "text" && ( {qd.mode !== "text" && (
<div className="flex flex-col gap-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}> <div className="flex flex-col gap-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
@@ -154,7 +163,7 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
{isSel && <span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>{qd.mode === "multi-select" ? "✓" : ""}</span>} {isSel && <span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>{qd.mode === "multi-select" ? "✓" : ""}</span>}
</span> </span>
<span> <span>
{opt.label} <MessageResponse>{opt.label}</MessageResponse>
{opt.description && <span className="block text-xs text-muted-foreground">{opt.description}</span>} {opt.description && <span className="block text-xs text-muted-foreground">{opt.description}</span>}
</span> </span>
</button> </button>
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { MenuIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/src/store/chats";
/**
* The app's primary navigation: a hamburger that opens the session list.
* Sessions only — settings and other chrome will dock here later.
* Renders the toggle button (sits in the page header) plus the slide-over.
*/
export function SessionDrawer() {
const [open, setOpen] = useState(false);
const [chats, setChats] = useState<ChatSummary[] | null>(null);
const router = useRouter();
const pathname = usePathname();
const load = useCallback(async () => {
try {
const res = await fetch("/api/chats", { cache: "no-store" });
const data = (await res.json()) as { chats: ChatSummary[] };
setChats(data.chats);
} catch {
setChats([]);
}
}, []);
useEffect(() => {
if (open) void load();
}, [open, load]);
// ESC closes; body scroll locks while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open]);
const go = (href: string) => {
setOpen(false);
router.push(href);
};
return (
<>
<Button variant="ghost" size="icon" aria-label="Open sessions" onClick={() => setOpen(true)}>
<MenuIcon className="size-5" />
</Button>
{/* backdrop */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/50 transition-opacity",
open ? "opacity-100" : "pointer-events-none opacity-0",
)}
onClick={() => setOpen(false)}
/>
{/* panel */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r bg-background transition-transform duration-200",
open ? "translate-x-0" : "-translate-x-full",
)}
aria-hidden={!open}
>
<div className="flex items-center justify-between border-b px-4 py-3">
<button
type="button"
onClick={() => go("/")}
className="font-semibold tracking-tight hover:opacity-80"
>
Learning Harness
</button>
<Button variant="ghost" size="icon" aria-label="Close sessions" onClick={() => setOpen(false)}>
<XIcon className="size-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{chats === null ? (
<div className="px-2 py-4 text-sm text-muted-foreground">Loading…</div>
) : chats.length === 0 ? (
<div className="px-2 py-4 text-sm text-muted-foreground">No sessions yet.</div>
) : (
chats.map((c) => {
const active = pathname === `/chat/${c.id}`;
return (
<button
key={c.id}
type="button"
onClick={() => go(`/chat/${c.id}`)}
className={cn(
"mb-0.5 w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
active && "bg-accent font-medium",
)}
>
<div className="truncate">{c.title}</div>
<div className="text-xs text-muted-foreground">
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleDateString()}
</div>
</button>
);
})
)}
</div>
</aside>
</>
);
}
+1
View File
@@ -23,6 +23,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"katex": "^0.16.47",
"lucide-react": "^1.33.0", "lucide-react": "^1.33.0",
"media-chrome": "^4.19.2", "media-chrome": "^4.19.2",
"motion": "^13.1.1", "motion": "^13.1.1",
+1
View File
@@ -30,6 +30,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"katex": "^0.16.47",
"lucide-react": "^1.33.0", "lucide-react": "^1.33.0",
"media-chrome": "^4.19.2", "media-chrome": "^4.19.2",
"motion": "^13.1.1", "motion": "^13.1.1",
+2
View File
@@ -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. 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 ## 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. 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.
+27 -2
View File
@@ -115,10 +115,35 @@ export function normalizeOptions(
/** Fisher-Yates over a copy. correctAnswer is keyed by value, so display /** Fisher-Yates over a copy. correctAnswer is keyed by value, so display
* order can be shuffled safely before resolving indices. */ * 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]; const out = [...options];
for (let i = out.length - 1; i > 0; i--) { 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 a = out[i]!;
const b = out[j]!; const b = out[j]!;
out[i] = b; out[i] = b;
+60
View File
@@ -15,6 +15,7 @@ import React from "react";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { MessageResponse } from "../components/ai-elements/message.tsx";
import { QuizCard } from "../components/quiz-card.tsx"; import { QuizCard } from "../components/quiz-card.tsx";
import { AnsweredCard } from "../components/answered-card.tsx"; import { AnsweredCard } from "../components/answered-card.tsx";
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; 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/); assert.match(html, /skipped/);
}); });
}); });
describe("math rendering (streamdown + KaTeX)", () => {
it("renders INLINE math ($...$) — the LLM-native convention", () => {
const html = renderToStaticMarkup(<MessageResponse>{"The map $f(x) = x^2$ is smooth."}</MessageResponse>);
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(<MessageResponse>{"$$\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n$$"}</MessageResponse>);
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(<MessageResponse>{"$$\\int_0^1 x^2\\,dx$$"}</MessageResponse>);
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(<MessageResponse>{"It costs \\$5 and \\$10 more."}</MessageResponse>);
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(
<QuizCard toolName="quiz" toolCallId="m1" input={mathQuiz} onSubmit={() => {}} />,
);
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(
<AnsweredCard answer={buildAnswer(qd, { values: ["$2x$"] })} />,
);
assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders");
});
});