Fix ordering giveaways, the stuck false label, add Regenerate deck

Ordering cards
- Generation prompt now forbids dates, years, times, step numbers and
  ordinals in ordering items (spec + hard rules), and the server-side
  validation drops any ordering card whose items still contain them
  instead of shipping a giveaway.

True/False
- The 'false ✕' swipe label was permanently visible on the card: its
  opacity curve was inverted, so the clamp at rest was 1. Flipped the
  range (and tightened both ranges for the elastic drag) — each label now
  only fades in while swiping in that direction.

Regenerate deck
- Results screen gains a Regenerate deck button: rebuilds the activities
  from the deck's stored source notes, replaces the deck in storage
  (attempts/best score reset) and starts the fresh run from slide 1.
- StudyFeed tracks the current deck so the header, export and messages use
  the regenerated title/activities.
- Fixed a spurious 0% attempt recorded when a regenerated deck swapped in:
  scroll anchoring held the old results position, so the feed briefly
  reported 5/5 with zero answers. The scroll now resets before the new
  slides render, overflow-anchor is disabled on the feed, and a run with
  no answers is never recorded.

Tests: new deck-fixes suite 15/15 (TF overlay at rest/drag, regenerate
with loading state + storage reset, ordering guard drop/keep); retry
14/14, deck chrome 15/15, pwa 16/16, final-ux 12/12, security 8/8,
contrast 20/20.
This commit is contained in:
Martin Dvořák 2026-10-07 11:25:22 +00:00
commit 33967f9454
6 changed files with 122 additions and 16 deletions

View file

@ -71,6 +71,9 @@ no database, no data leaving your machine except the requests you send to Ollama
- Arrow-key / PageUp / PageDown / Home / End navigation, plus swipe on touch devices - Arrow-key / PageUp / PageDown / Home / End navigation, plus swipe on touch devices
- Progress bar, activity counter, XP counter and answer streak in the chrome - Progress bar, activity counter, XP counter and answer streak in the chrome
- Instant feedback with a short explanation for every activity, confetti when you finish - Instant feedback with a short explanation for every activity, confetti when you finish
- Results screen: **Retry missed ones** (fresh, answerable items), **Study the whole deck again**,
and **Regenerate deck** — rebuilds the activities from the stored notes so you can keep drilling
until you're ready
**Activity types** (each one is its own React component, rendered from JSON by a `type` field) **Activity types** (each one is its own React component, rendered from JSON by a `type` field)
@ -98,7 +101,8 @@ no database, no data leaving your machine except the requests you send to Ollama
- **Folders**: create named groups ("History", "Biology", …) from the sidebar or the create screen. - **Folders**: create named groups ("History", "Biology", …) from the sidebar or the create screen.
New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**. New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**.
Rename, delete (decks survive — they return to Unorganized) and move decks between folders Rename, delete (decks survive — they return to Unorganized) and move decks between folders
- Rename, delete (with confirm), regenerate, and **add more activities** to an existing deck - Rename, delete (with confirm), regenerate (from the library or the results screen), and
**add more activities** to an existing deck
- Export a deck as JSON and import it back — the folder name travels with the file and is recreated - Export a deck as JSON and import it back — the folder name travels with the file and is recreated
on import on import

View file

@ -6,7 +6,7 @@ import { motion } from "framer-motion";
import type { Activity, Deck } from "@/lib/schemas"; import type { Activity, Deck } from "@/lib/schemas";
import { downloadDeck } from "@/lib/storage"; import { downloadDeck } from "@/lib/storage";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon } from "@/components/icons"; import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
import { Button, Chip } from "@/components/ui"; import { Button, Chip } from "@/components/ui";
import type { AttemptResult } from "./StudyFeed"; import type { AttemptResult } from "./StudyFeed";
@ -60,6 +60,9 @@ export function ResultsScreen({
retryMode, retryMode,
onRetryMissed, onRetryMissed,
onRestart, onRestart,
onRegenerate,
regenerating,
regenerateError,
}: { }: {
deck: Deck; deck: Deck;
xp: number; xp: number;
@ -71,6 +74,9 @@ export function ResultsScreen({
retryMode: boolean; retryMode: boolean;
onRetryMissed: () => void; onRetryMissed: () => void;
onRestart: () => void; onRestart: () => void;
onRegenerate: () => void;
regenerating: boolean;
regenerateError: string | null;
}) { }) {
const percent = total > 0 ? Math.round((correct / total) * 100) : 0; const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
const perfect = missed.length === 0 && total > 0; const perfect = missed.length === 0 && total > 0;
@ -151,6 +157,21 @@ export function ResultsScreen({
<RefreshIcon className="size-5" /> Study the whole deck again <RefreshIcon className="size-5" /> Study the whole deck again
</Button> </Button>
) : null} ) : null}
<Button
size="lg"
variant="secondary"
onClick={onRegenerate}
loading={regenerating}
disabled={regenerating}
>
<SparklesIcon className="size-5" />
{regenerating ? "Regenerating…" : "Regenerate deck"}
</Button>
{regenerateError ? (
<p className="text-center text-xs text-rose-600 dark:text-rose-300" role="alert">
{regenerateError}
</p>
) : null}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={() => downloadDeck(deck)}> <Button variant="ghost" onClick={() => downloadDeck(deck)}>
<DownloadIcon className="size-4" /> Export JSON <DownloadIcon className="size-4" /> Export JSON

View file

@ -6,8 +6,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities"; import { ActivityRenderer } from "@/components/activities";
import { celebrate } from "@/lib/confetti"; import { celebrate } from "@/lib/confetti";
import { ApiError, generateDeck } from "@/lib/client-api";
import { materialiseActivities } from "@/lib/deck-ops";
import type { Activity, Deck } from "@/lib/schemas"; import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt } from "@/lib/storage"; import { recordAttempt, upsertDeck } from "@/lib/storage";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons"; import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui"; import { Button, ProgressBar } from "@/components/ui";
@ -17,6 +19,7 @@ import { ResultsScreen } from "./ResultsScreen";
export type AttemptResult = { correct: boolean; userAnswer?: string }; export type AttemptResult = { correct: boolean; userAnswer?: string };
export function StudyFeed({ deck }: { deck: Deck }) { export function StudyFeed({ deck }: { deck: Deck }) {
const [currentDeck, setCurrentDeck] = useState(deck);
const [queue, setQueue] = useState<Activity[]>(deck.activities); const [queue, setQueue] = useState<Activity[]>(deck.activities);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [results, setResults] = useState<Record<string, AttemptResult>>({}); const [results, setResults] = useState<Record<string, AttemptResult>>({});
@ -24,6 +27,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const [streak, setStreak] = useState(0); const [streak, setStreak] = useState(0);
const [bestStreak, setBestStreak] = useState(0); const [bestStreak, setBestStreak] = useState(0);
const [retryMode, setRetryMode] = useState(false); const [retryMode, setRetryMode] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [regenerateError, setRegenerateError] = useState<string | null>(null);
/** Bumped on every restart so the activity components remount fresh. */ /** Bumped on every restart so the activity components remount fresh. */
const [runId, setRunId] = useState(0); const [runId, setRunId] = useState(0);
@ -95,10 +100,15 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const answered = Object.values(results); const answered = Object.values(results);
const correct = answered.filter((entry) => entry.correct).length; const correct = answered.filter((entry) => entry.correct).length;
const score = total > 0 ? (correct / total) * 100 : 0; const score = total > 0 ? (correct / total) * 100 : 0;
recordAttempt(deck.id, { scorePercent: score, correct, total }); // Only a run with at least one answer counts. Without this, the scroll
// anchoring shift when a regenerated deck swaps in could fire a bogus
// 0% attempt before the feed had snapped back to the first slide.
if (answered.length > 0) {
recordAttempt(currentDeck.id, { scorePercent: score, correct, total });
void celebrate("big"); void celebrate("big");
} }
}, [finished, results, total, deck.id, retryMode]); }
}, [finished, results, total, currentDeck.id, retryMode]);
const handleAnswered = useCallback( const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => { (activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
@ -136,7 +146,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
setStreak(0); setStreak(0);
setRetryMode(retry); setRetryMode(retry);
recordedRef.current = retry; // don't overwrite the best score with a retry run recordedRef.current = retry; // don't overwrite the best score with a retry run
// A new run: remount every activity (fresh state) and jump to the top. // A new run: remount every activity (fresh state) and jump to the top
// *before* the new slides render, so scroll anchoring can't hold the
// old results position.
const container = scrollRef.current;
if (container) container.scrollTop = 0;
setRunId((value) => value + 1); setRunId((value) => value + 1);
setIndex(0); setIndex(0);
}, },
@ -149,6 +163,45 @@ export function StudyFeed({ deck }: { deck: Deck }) {
if (container) container.scrollTop = 0; if (container) container.scrollTop = 0;
}, [runId]); }, [runId]);
/** Rebuild the deck from its stored source notes and start it over. */
const regenerate = useCallback(async () => {
if (regenerating) return;
const source = currentDeck.sourceText?.trim();
if (!source) {
setRegenerateError("This deck has no stored source notes, so it can't be regenerated.");
return;
}
setRegenerating(true);
setRegenerateError(null);
try {
const { deck: generated } = await generateDeck({
text: source,
difficulty: currentDeck.difficulty,
language: currentDeck.language,
count: Math.max(8, currentDeck.activities.length),
});
const activities = materialiseActivities(generated.activities, currentDeck.difficulty);
const updated: Deck = {
...currentDeck,
title: generated.title || currentDeck.title,
subject: generated.subject ?? currentDeck.subject,
summary: generated.summary ?? currentDeck.summary,
language: generated.language ?? currentDeck.language,
activities,
attempts: 0,
bestScore: undefined,
updatedAt: Date.now(),
};
upsertDeck(updated);
setCurrentDeck(updated);
restart(activities, false);
} catch (error) {
setRegenerateError(error instanceof ApiError ? error.message : "Could not regenerate the deck.");
} finally {
setRegenerating(false);
}
}, [currentDeck, regenerating, restart]);
const progressValue = Math.min(index + 1, slideCount); const progressValue = Math.min(index + 1, slideCount);
const progressMax = slideCount; const progressMax = slideCount;
@ -197,7 +250,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]"> <div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
<span className="min-w-0 truncate">{deck.title}</span> <span className="min-w-0 truncate">{currentDeck.title}</span>
<span className="shrink-0"> <span className="shrink-0">
{retryMode ? "Retry · " : ""} {retryMode ? "Retry · " : ""}
{Math.min(index + 1, total)}/{total} {Math.min(index + 1, total)}/{total}
@ -210,7 +263,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<div <div
ref={scrollRef} ref={scrollRef}
data-testid="study-feed" data-testid="study-feed"
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain" className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
> >
{queue.map((activity, activityIndex) => ( {queue.map((activity, activityIndex) => (
<section <section
@ -222,7 +275,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<ActivityRenderer <ActivityRenderer
activity={activity} activity={activity}
active={index === activityIndex} active={index === activityIndex}
language={deck.language} language={currentDeck.language}
isLast={activityIndex === total - 1} isLast={activityIndex === total - 1}
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)} onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
onNext={() => goTo(activityIndex + 1)} onNext={() => goTo(activityIndex + 1)}
@ -237,7 +290,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
<ResultsScreen <ResultsScreen
deck={deck} deck={currentDeck}
xp={xp} xp={xp}
bestStreak={bestStreak} bestStreak={bestStreak}
total={total} total={total}
@ -246,7 +299,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
results={results} results={results}
retryMode={retryMode} retryMode={retryMode}
onRetryMissed={() => restart(missed, true)} onRetryMissed={() => restart(missed, true)}
onRestart={() => restart(deck.activities, false)} onRestart={() => restart(currentDeck.activities, false)}
onRegenerate={regenerate}
regenerating={regenerating}
regenerateError={regenerateError}
/> />
</div> </div>
</section> </section>

View file

@ -19,8 +19,13 @@ type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) { export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
const [result, setResult] = useState<boolean | null>(null); const [result, setResult] = useState<boolean | null>(null);
const x = useMotionValue(0); const x = useMotionValue(0);
const trueOpacity = useTransform(x, [30, 110], [0, 1]); // The drag is elastic, so the card moves less than the pointer — these
const falseOpacity = useTransform(x, [-30, -110], [1, 0]); // ranges make each label reach full strength on a normal swipe.
const trueOpacity = useTransform(x, [20, 70], [0, 1]);
// Left drags go negative: at rest (0) this must be invisible, only fading
// in as the card is dragged left. The old range was inverted, so the
// "false ✕" label sat on the card permanently.
const falseOpacity = useTransform(x, [-70, -20], [1, 0]);
const answer = (value: boolean) => { const answer = (value: boolean) => {
if (result !== null) return; if (result !== null) return;

View file

@ -250,6 +250,13 @@ const VALID_TYPES = [
"short_answer", "short_answer",
] as const; ] as const;
/**
* Giveaways that make an ordering card solvable without knowing the material:
* years, clock times, "first/next/finally", "step 2", "1.".
*/
const ORDERING_GIVEAWAY =
/\b(1[0-9]{3}|20[0-9]{2})\b|\b\d{1,2}[:.]\d{2}\b|\b(first|second|third|fourth|fifth|sixth|seventh|eighth|1st|2nd|3rd|4th|5th|6th|7th|8th)\b|\b(step|stage|phase)\s*\d+\b|^\s*\d+[.)]\s/i;
/** /**
* Be forgiving: validate each activity on its own and keep the good ones. * Be forgiving: validate each activity on its own and keep the good ones.
* Only if too few survive does the step get retried. * Only if too few survive does the step get retried.
@ -280,7 +287,16 @@ function validateDeck(parsed: unknown, minimum: number, requested: number) {
rawActivities.slice(0, 24).forEach((item, index) => { rawActivities.slice(0, 24).forEach((item, index) => {
const result = generatedActivitySchema.safeParse(item); const result = generatedActivitySchema.safeParse(item);
if (result.success) { if (result.success) {
activities.push(sanitiseActivity(result.data)); const activity = sanitiseActivity(result.data);
// An ordering card with dates/ordinals on it gives the answer away —
// drop it rather than ship a card the learner can solve without knowing.
if (activity.type === "ordering" && activity.items.some((entry) => ORDERING_GIVEAWAY.test(entry))) {
rejected.push(
`#${index + 1} (ordering): items must not contain dates, years, times or ordinals — the order has to come from the content.`,
);
return;
}
activities.push(activity);
} else { } else {
const type = (item as { type?: unknown } | null)?.type; const type = (item as { type?: unknown } | null)?.type;
rejected.push(`#${index + 1} (${typeof type === "string" ? type : "no type"}): ${summariseZodError(result.error)}`); rejected.push(`#${index + 1} (${typeof type === "string" ? type : "no type"}): ${summariseZodError(result.error)}`);

View file

@ -85,7 +85,9 @@ export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type
{ "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." } { "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." }
6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them) 6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them)
{ "type": "ordering", "topic": "short label", "instruction": "Put the stages of mitosis in order.", "items": ["Prophase", "Metaphase", "Anaphase", "Telophase"], "explanation": "..." } Items must NEVER contain dates, years, clock times, step numbers or ordinals ("first", "step 2") —
the learner has to work out the order from what each item says, not read it off.
{ "type": "ordering", "topic": "short label", "instruction": "Put the events in the order they happened.", "items": ["The Allies land in Normandy", "Paris is liberated", "Germany surrenders"], "explanation": "..." }
7. short_answer - typed answer, graded by AI 7. short_answer - typed answer, graded by AI
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea that must appear", "second idea"], "explanation": "..." } { "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea that must appear", "second idea"], "explanation": "..." }
@ -102,6 +104,8 @@ Hard rules:
- Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row. - Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row.
- Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts. - Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts.
- Make the wrong options plausible (common mix-ups from the same notes), never silly filler. - Make the wrong options plausible (common mix-ups from the same notes), never silly filler.
- Ordering items must never reveal their position: no dates, years, times, step numbers or ordinals
("first", "next", "finally", "step 2"). Describe the event or stage itself.
- Keep questions short enough to read on a phone screen. No walls of text. - Keep questions short enough to read on a phone screen. No walls of text.
- Never reference "the notes", "the text above" or "the student". Speak directly to the learner. - Never reference "the notes", "the text above" or "the student". Speak directly to the learner.
- Match the requested difficulty: easy = recall and simple recognition; medium = understanding and application; hard = analysis, edge cases and multi-step reasoning. - Match the requested difficulty: easy = recall and simple recognition; medium = understanding and application; hard = analysis, edge cases and multi-step reasoning.