Numeroud bug fixes, UI improvements, small animations
Some checks failed
Automated Container Build / build-and-push (push) Failing after 3s

This commit is contained in:
Elijah 2026-06-27 19:50:10 -07:00
parent b7ce314f01
commit d76ec4a69f
10 changed files with 249 additions and 82 deletions

BIN
dev.db

Binary file not shown.

View file

@ -18,11 +18,13 @@ interface DeckItem {
}>;
}
const cache: Record<string, DeckItem[]> = {};
export default function FlashcardsPage() {
const params = useParams();
const classSlug = params.classSlug as string;
const [decks, setDecks] = useState<DeckItem[]>([]);
const [loading, setLoading] = useState(true);
const [decks, setDecks] = useState<DeckItem[]>(cache[classSlug] || []);
const [loading, setLoading] = useState(!cache[classSlug]);
const [showImport, setShowImport] = useState(false);
const [classId, setClassId] = useState<string>("");
const [editingId, setEditingId] = useState<string | null>(null);
@ -33,7 +35,9 @@ export default function FlashcardsPage() {
const res = await fetch(`/api/decks/list?classId=${cId}`);
if (res.ok) {
const data = await res.json();
setDecks(Array.isArray(data) ? data : []);
const arr = Array.isArray(data) ? data : [];
setDecks(arr);
cache[classSlug] = arr;
}
} catch {
setDecks([]);
@ -124,7 +128,7 @@ export default function FlashcardsPage() {
{/* Empty state */}
{!loading && decks.length === 0 && (
<div className="text-center py-16">
<div className="text-center py-16 animate-fade-in">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@ -143,16 +147,16 @@ export default function FlashcardsPage() {
{/* Deck grid */}
{!loading && decks.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in">
{decks.map((deck) => {
const progressLabel = getProgressLabel(deck);
return (
<div
key={deck.id}
className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
className="group flex flex-col bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300 h-full"
>
<div className="p-6">
<div className="p-6 flex flex-col flex-1 h-full">
{/* Name (editable) */}
{editingId === deck.id ? (
<div className="flex gap-2 mb-3">
@ -188,7 +192,7 @@ export default function FlashcardsPage() {
)}
{/* Stats */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 mt-auto mb-4">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{deck._count.cards} {deck._count.cards === 1 ? "card" : "cards"}
</span>
@ -198,13 +202,40 @@ export default function FlashcardsPage() {
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light">
<Link
href={`/${classSlug}/flashcards/${deck.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
{deck.progress?.length ? "Continue" : "Study"}
</Link>
<div className="flex items-center gap-2 pt-4 border-t border-border-light">
{deck.progress?.length ? (
<>
<Link
href={`/${classSlug}/flashcards/${deck.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
Continue
</Link>
<button
onClick={async () => {
if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return;
await Promise.all([
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) }).catch(() => {}),
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) }).catch(() => {})
]);
window.location.href = `/${classSlug}/flashcards/${deck.id}`;
}}
className="p-2 rounded-lg text-text-muted border border-border-light hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer"
title="Start New"
>
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</>
) : (
<Link
href={`/${classSlug}/flashcards/${deck.id}`}
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
>
Study
</Link>
)}
<button
onClick={() => {
setEditingId(deck.id);

View file

@ -56,6 +56,24 @@ export default function QuizStudyPage() {
const [loading, setLoading] = useState(true);
const [view, setView] = useState<"history" | "take">("take");
const [retakeIds, setRetakeIds] = useState<string[] | null>(null);
const [restartKey, setRestartKey] = useState(0);
async function handleRestart() {
if (!quiz) return;
// Clear progress in database
await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" })
}).catch(() => {});
// Clear locally and force remount
setQuiz({ ...quiz, progress: [] });
setRetakeIds(null);
setRestartKey(prev => prev + 1);
setView("take");
}
const fetchQuizAndAttempts = useCallback(async () => {
try {
@ -121,7 +139,18 @@ export default function QuizStudyPage() {
</div>
{/* View toggle & Share */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 md:gap-4">
{view === "take" && (
<button
onClick={handleRestart}
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
title="Restart Quiz"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
{attempts.length > 0 && (
@ -163,6 +192,7 @@ export default function QuizStudyPage() {
/>
) : (
<QuizViewer
key={restartKey}
quiz={quiz}
retakeIds={retakeIds}
onFinished={() => {

View file

@ -12,11 +12,13 @@ interface QuizItem {
_count: { questions: number; attempts: number };
}
const cache: Record<string, QuizItem[]> = {};
export default function QuizzesPage() {
const params = useParams();
const classSlug = params.classSlug as string;
const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
const [loading, setLoading] = useState(true);
const [quizzes, setQuizzes] = useState<QuizItem[]>(cache[classSlug] || []);
const [loading, setLoading] = useState(!cache[classSlug]);
const [showImport, setShowImport] = useState(false);
const [classId, setClassId] = useState<string>("");
@ -25,7 +27,9 @@ export default function QuizzesPage() {
const res = await fetch(`/api/quizzes/list?classId=${cId}`);
if (res.ok) {
const data = await res.json();
setQuizzes(Array.isArray(data) ? data : []);
const arr = Array.isArray(data) ? data : [];
setQuizzes(arr);
cache[classSlug] = arr;
}
} catch {
setQuizzes([]);
@ -89,7 +93,7 @@ export default function QuizzesPage() {
{/* Empty state */}
{!loading && quizzes.length === 0 && (
<div className="text-center py-16">
<div className="text-center py-16 animate-fade-in">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
@ -108,7 +112,7 @@ export default function QuizzesPage() {
{/* Quiz grid */}
{!loading && quizzes.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in">
{quizzes.map((quiz) => (
<div
key={quiz.id}

View file

@ -16,6 +16,9 @@ export default function HomePage() {
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
useEffect(() => {
fetchClasses();
@ -60,6 +63,27 @@ export default function HomePage() {
fetchClasses();
}
async function handleSaveEdit(id: string) {
if (!editName.trim()) {
setEditingId(null);
return;
}
setSavingEdit(true);
try {
const res = await fetch(`/api/classes/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName.trim() }),
});
if (res.ok) {
setEditingId(null);
fetchClasses();
}
} finally {
setSavingEdit(false);
}
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
@ -167,9 +191,36 @@ export default function HomePage() {
href={`/${cls.slug}`}
className="block p-6"
>
<h3 className="text-lg font-semibold text-text-heading group-hover:text-primary transition-colors">
{cls.name}
</h3>
{editingId === cls.id ? (
<input
type="text"
value={editName}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleSaveEdit(cls.id);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setEditingId(null);
}
}}
onBlur={() => handleSaveEdit(cls.id)}
autoFocus
disabled={savingEdit}
className="w-[calc(100%-2.5rem)] text-lg font-semibold text-text-heading px-2 py-1 -ml-2 rounded bg-bg-surface-alt border border-primary/30 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
) : (
<h3 className="text-lg font-semibold text-text-heading group-hover:text-primary transition-colors">
{cls.name}
</h3>
)}
<div className="flex gap-4 mt-3">
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -186,19 +237,38 @@ export default function HomePage() {
</div>
</Link>
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(cls.id, cls.name);
}}
className="absolute top-3 right-3 p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg opacity-0 group-hover:opacity-100 transition-all duration-200 cursor-pointer"
title="Delete class"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/* Actions */}
<div className="absolute top-3 right-3 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
{/* Delete button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(cls.id, cls.name);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-colors cursor-pointer"
title="Delete class"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/* Edit button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditingId(cls.id);
setEditName(cls.name);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer"
title="Rename class"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
</div>
))}
</div>

View file

@ -77,6 +77,10 @@ body {
transform: rotateY(180deg);
}
.rotate-x-180 {
transform: rotateX(180deg);
}
/* ── Swipe animations ── */
@keyframes swipeLeft {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }

View file

@ -121,7 +121,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
function handleKeyDown(e: KeyboardEvent) {
if (!started || completed) return;
if (e.key === " " || e.key === "Enter") {
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
setIsFlipped((prev) => {
if (!prev) setHasFlippedOnce(true);
@ -358,8 +358,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
>
<div
key={currentCard.id}
className={`relative w-full min-h-[450px] md:min-h-[550px] preserve-3d ${!swipeClass ? "transition-transform duration-500" : ""} ${
isFlipped ? "rotate-y-180" : ""
className={`relative w-full min-h-[450px] md:min-h-[550px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
isFlipped ? "rotate-x-180" : ""
}`}
>
{/* Front */}
@ -378,7 +378,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</div>
{/* Back */}
<div className="absolute inset-0 backface-hidden rotate-y-180 bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<div className="absolute inset-0 backface-hidden rotate-x-180 bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Back
</span>
@ -412,35 +412,60 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
)}
</div>
{/* Grading buttons (visible after first flip) */}
{hasFlippedOnce && (
<div className="flex items-center justify-center gap-4 mt-6 animate-fade-in">
<div className="relative w-full mt-6">
{currentIndex > 0 && (
<button
onClick={() => gradeCard("missed")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-error/30 text-error font-medium hover:bg-error-bg transition-all duration-200 cursor-pointer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Missed
</button>
<button
onClick={() => gradeCard("correct")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-success/30 text-success font-medium hover:bg-success-bg transition-all duration-200 cursor-pointer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Got It
</button>
</div>
)}
onClick={() => {
const prevIndex = currentIndex - 1;
setCurrentIndex(prevIndex);
setIsFlipped(false);
setHasFlippedOnce(false);
{/* Keyboard hint */}
<div className="text-center mt-4 text-xs text-text-muted">
{hasFlippedOnce
? "← Missed · Got It →"
: "Space to flip"}
const prevId = order[prevIndex];
const newResults = { ...results };
delete newResults[prevId];
setResults(newResults);
saveProgress(order, prevIndex, newResults, mode);
}}
className="absolute left-0 top-0 p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer z-10"
title="Previous Card"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
)}
<div className="flex items-center justify-center gap-4 min-h-[52px]">
{hasFlippedOnce && (
<div className="flex items-center gap-4 animate-fade-in">
<button
onClick={() => gradeCard("missed")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-error/30 text-error font-medium hover:bg-error-bg transition-all duration-200 cursor-pointer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Missed
</button>
<button
onClick={() => gradeCard("correct")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-success/30 text-success font-medium hover:bg-success-bg transition-all duration-200 cursor-pointer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Got It
</button>
</div>
)}
</div>
<div className="text-center mt-4 text-xs text-text-muted">
{hasFlippedOnce
? "← Missed · Got It →"
: "Space to flip"}
</div>
</div>
</div>
)}

View file

@ -68,7 +68,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="flex justify-center items-end gap-2 mb-6">
<span className="text-5xl font-black text-text-heading">{Math.round(percentage)}%</span>
<span className="text-lg text-text-muted mb-1 font-medium">({attempt.score.toFixed(2)} / {attempt.maxScore})</span>
<span className="text-lg text-text-muted mb-1 font-medium">({parseFloat(attempt.score.toFixed(2))} / {attempt.maxScore})</span>
</div>
<div className="flex justify-center gap-4">
@ -107,7 +107,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
return (
<div key={q.id} className="bg-bg-surface rounded-2xl border border-border-light shadow-sm overflow-hidden">
<div className="bg-error-bg/30 border-b border-error/20 px-6 py-3 flex items-center justify-between">
<span className="text-sm font-medium text-error">Score: {item.score.toFixed(2)} / 1</span>
<span className="text-sm font-medium text-error">Score: {parseFloat(item.score.toFixed(2))} / 1</span>
<span className="text-xs font-bold uppercase tracking-wider text-text-muted">{q.category}</span>
</div>
<div className="p-6">

View file

@ -271,7 +271,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
<div>
<span className="text-sm font-medium text-text-secondary">Score so far: </span>
<span className="text-sm font-bold text-primary">
{runningScore.toFixed(2)} / {maxPossibleSoFar}
{parseFloat(runningScore.toFixed(2))} / {maxPossibleSoFar}
</span>
</div>
</div>
@ -334,21 +334,24 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
className={`flex items-start gap-4 p-4 rounded-xl border-2 transition-all duration-200 ${stateClass}`}
>
<div className="pt-0.5">
{icon ? icon : (
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
isSelected ? 'border-primary bg-primary text-white' : 'border-border-light bg-bg-surface'
}`}>
{isSelected && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
)}
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
isSelected ? 'border-primary bg-primary text-white' : 'border-border-light bg-bg-surface'
}`}>
{isSelected && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</div>
<div className="flex-1 text-text-body font-medium leading-snug">
{opt.text}
</div>
{icon && (
<div className="pt-0.5 pl-2">
{icon}
</div>
)}
</div>
);
})}

View file

@ -10,9 +10,9 @@ For flashcards, use this shape:
{ "type": "flashcards", "deckName": "...", "description": "...", "cards": [{"front": "...", "back": "..."}] }
Rules:
- CRITICAL: Output MUST be valid JSON. All strings must be wrapped in double quotes.
- CRITICAL: Output MUST be valid JSON. EVERY string value must start and end with double quotes. Do NOT output unquoted strings. Example: "back": "your text here".
- Use Markdown formatting (bullet lists, **bold**, tables) inside "back"
text wherever it improves clarity. You MUST escape any newlines in the string as \n. Do not use raw newlines.
text wherever it improves clarity. You MUST escape any newlines in the string as \\n. Do not use raw newlines.
- Base everything strictly on the material below. Do not add outside facts.
Material:
@ -31,7 +31,7 @@ For quizzes, use this shape:
]}
Rules:
- CRITICAL: Output MUST be valid JSON. All strings must be wrapped in double quotes.
- CRITICAL: Output MUST be perfectly valid JSON. EVERY string value (including rationale and prompt) MUST start and end with double quotes. Do NOT output unquoted strings. Example: "rationale": "your text here"
- "multiple_choice" questions must have exactly one option with correct: true.
- "sata" questions must have two or more options with correct: true.
- Every question needs a rationale explaining why the correct option(s) are
@ -42,7 +42,7 @@ Rules:
a new variant each time, and keep the total number of distinct categories
small relative to the number of questions.
- Use Markdown formatting (bullet lists, **bold**, tables) inside
"rationale" text wherever it improves clarity. You MUST escape any newlines in the string as \n. Do not use raw newlines.
"rationale" text wherever it improves clarity. You MUST escape any newlines in the string as \\n. Do not use raw newlines.
- Base everything strictly on the material below. Do not add outside facts.
Material: