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

@ -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({
<RefreshIcon className="size-5" /> Study the whole deck again
</Button>
) : 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">
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
<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 { 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 { recordAttempt } from "@/lib/storage";
import { recordAttempt, upsertDeck } from "@/lib/storage";
import { cn } from "@/lib/utils";
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
@ -17,6 +19,7 @@ import { ResultsScreen } from "./ResultsScreen";
export type AttemptResult = { correct: boolean; userAnswer?: string };
export function StudyFeed({ deck }: { deck: Deck }) {
const [currentDeck, setCurrentDeck] = useState(deck);
const [queue, setQueue] = useState<Activity[]>(deck.activities);
const [index, setIndex] = useState(0);
const [results, setResults] = useState<Record<string, AttemptResult>>({});
@ -24,6 +27,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const [streak, setStreak] = useState(0);
const [bestStreak, setBestStreak] = useState(0);
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. */
const [runId, setRunId] = useState(0);
@ -95,10 +100,15 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const answered = Object.values(results);
const correct = answered.filter((entry) => entry.correct).length;
const score = total > 0 ? (correct / total) * 100 : 0;
recordAttempt(deck.id, { scorePercent: score, correct, total });
void celebrate("big");
// 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");
}
}
}, [finished, results, total, deck.id, retryMode]);
}, [finished, results, total, currentDeck.id, retryMode]);
const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
@ -136,7 +146,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
setStreak(0);
setRetryMode(retry);
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);
setIndex(0);
},
@ -149,6 +163,45 @@ export function StudyFeed({ deck }: { deck: Deck }) {
if (container) container.scrollTop = 0;
}, [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 progressMax = slideCount;
@ -197,7 +250,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
</div>
</div>
<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">
{retryMode ? "Retry · " : ""}
{Math.min(index + 1, total)}/{total}
@ -210,7 +263,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<div
ref={scrollRef}
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) => (
<section
@ -222,7 +275,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<ActivityRenderer
activity={activity}
active={index === activityIndex}
language={deck.language}
language={currentDeck.language}
isLast={activityIndex === total - 1}
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
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]">
<ResultsScreen
deck={deck}
deck={currentDeck}
xp={xp}
bestStreak={bestStreak}
total={total}
@ -246,7 +299,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
results={results}
retryMode={retryMode}
onRetryMissed={() => restart(missed, true)}
onRestart={() => restart(deck.activities, false)}
onRestart={() => restart(currentDeck.activities, false)}
onRegenerate={regenerate}
regenerating={regenerating}
regenerateError={regenerateError}
/>
</div>
</section>

View file

@ -19,8 +19,13 @@ type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
const [result, setResult] = useState<boolean | null>(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;