diff --git a/README.md b/README.md
index 600a308..3d28fde 100644
--- a/README.md
+++ b/README.md
@@ -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
- 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
+- 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)
@@ -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.
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 (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
on import
diff --git a/components/ResultsScreen.tsx b/components/ResultsScreen.tsx
index 0042190..55f73a8 100644
--- a/components/ResultsScreen.tsx
+++ b/components/ResultsScreen.tsx
@@ -6,7 +6,7 @@ import { motion } from "framer-motion";
import type { Activity, Deck } from "@/lib/schemas";
import { downloadDeck } from "@/lib/storage";
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 type { AttemptResult } from "./StudyFeed";
@@ -60,6 +60,9 @@ export function ResultsScreen({
retryMode,
onRetryMissed,
onRestart,
+ onRegenerate,
+ regenerating,
+ regenerateError,
}: {
deck: Deck;
xp: number;
@@ -71,6 +74,9 @@ export function ResultsScreen({
retryMode: boolean;
onRetryMissed: () => void;
onRestart: () => void;
+ onRegenerate: () => void;
+ regenerating: boolean;
+ regenerateError: string | null;
}) {
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
const perfect = missed.length === 0 && total > 0;
@@ -151,6 +157,21 @@ export function ResultsScreen({
Study the whole deck again
) : null}
+
+ {regenerateError ? (
+
diff --git a/components/activities/TrueFalse.tsx b/components/activities/TrueFalse.tsx
index 8947f6b..7a27ce1 100644
--- a/components/activities/TrueFalse.tsx
+++ b/components/activities/TrueFalse.tsx
@@ -19,8 +19,13 @@ type TrueFalseActivity = Extract;
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) {
const [result, setResult] = useState(null);
const x = useMotionValue(0);
- const trueOpacity = useTransform(x, [30, 110], [0, 1]);
- const falseOpacity = useTransform(x, [-30, -110], [1, 0]);
+ // The drag is elastic, so the card moves less than the pointer — these
+ // 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) => {
if (result !== null) return;
diff --git a/lib/ai.ts b/lib/ai.ts
index 136c6e6..db049c5 100644
--- a/lib/ai.ts
+++ b/lib/ai.ts
@@ -250,6 +250,13 @@ const VALID_TYPES = [
"short_answer",
] 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.
* 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) => {
const result = generatedActivitySchema.safeParse(item);
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 {
const type = (item as { type?: unknown } | null)?.type;
rejected.push(`#${index + 1} (${typeof type === "string" ? type : "no type"}): ${summariseZodError(result.error)}`);
diff --git a/lib/prompts.ts b/lib/prompts.ts
index 000c214..ca5a444 100644
--- a/lib/prompts.ts
+++ b/lib/prompts.ts
@@ -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": "..." }
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
{ "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.
- 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.
+- 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.
- 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.