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

@ -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)}`);