diff --git a/.env.example b/.env.example
index fcdce26..9a3121a 100644
--- a/.env.example
+++ b/.env.example
@@ -90,4 +90,5 @@ TEXT_MODEL=gpt-oss:120b-cloud
# OLLAMA_HEALTH_TIMEOUT_MS=8000
# Send `think: false` to thinking models for faster JSON output.
# OLLAMA_THINK=false
+# Deck size when the request does not send one (6–30; the wizard offers 8–30).
# FREEZMO_DEFAULT_ACTIVITY_COUNT=10
diff --git a/README.md b/README.md
index d0b48c0..3c461a9 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
**Turn photos of your study notes into a swipeable feed of mini study games.**
Freezmo is a TikTok/Gizmo-style study app. You paste or upload photos of your notes, an AI reads
-them and builds a deck of 8–15 interactive activities — flashcards, quizzes, matching games and
+them and builds a deck of 8–30 interactive activities — flashcards, quizzes, matching games and
more — that you swipe through vertically like a feed. Every answer gives instant feedback, XP and
streaks, and the deck is saved in your browser so you can retry it later.
@@ -105,7 +105,8 @@ no database, no data leaving your machine except the requests you send to Ollama
- Up to 8 photos + 6 documents per deck (max 12 images reach the model); photos are resized to max
1600 px and re-encoded as JPEG before upload
- Optional extra text (pasted notes) and a topic hint
-- Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 activities per deck
+- Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 / 20 / 30 activities per deck (a big deck
+ is built in several model calls — see the generation pipeline)
- Fun staged loading state (`Reading your notes… → Finding the key ideas… → Building your activities…`)
- Friendly error cards with a **Retry** button that point at Settings when a key is needed
@@ -782,7 +783,7 @@ Limits and behaviour worth knowing:
▼ ▼
┌──────────────────────────┐ ┌───────────────────────────┐
│ browser: study feed │ ◄───────────────────── │ TEXT_MODEL │
- │ JSON validated by zod │ deck JSON │ design 8–15 activities │
+ │ JSON validated by zod │ deck JSON │ design 8–30 activities │
└──────────────────────────┘ └───────────────────────────┘
```
@@ -794,8 +795,14 @@ Limits and behaviour worth knowing:
with the validation error fed back to the model (`lib/ai.ts`).
- Individual broken activities are dropped rather than failing the whole deck; if fewer than half
the requested activities survive, the step is retried too.
+- **Bigger decks are built in batches.** One model reply has to be one valid JSON object, and past
+ ~18 activities replies get truncated or lose cards, so a count above that is split across several
+ calls (30 → 15 + 15, 25 → 13 + 12) with the same notes. Each call after the first is told the
+ deck's title, the types already used and the topics already covered, and the merged deck drops
+ cards whose content repeats (so the same idea never ships twice). The route's overall deadline
+ scales with the number of calls.
- Requests use `stream: false` with per-request timeouts and a hard overall deadline
- (two sequential model calls). Errors surface as friendly messages
+ (the vision step plus one model call per batch). Errors surface as friendly messages
("Ollama isn't reachable, check your settings") with a retry button.
- `POST /api/generate` answers with newline-delimited JSON (`stage` / `deck` / `error` events) so the
UI can show real progress between the vision and generation steps.
@@ -1513,6 +1520,22 @@ Things I chose while building, with the reasoning:
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged.
+112. **Bigger decks are one request but several model calls.** The create wizard topped out at 15
+ activities (`COUNT_OPTIONS`) because the API, the prompt and the salvage path all capped at 18:
+ one reply has to be one valid JSON object, and past that size models start truncating or
+ quietly dropping cards. Raising the cap alone would have shipped decks that came back short.
+ The picker now offers 8 / 10 / 12 / 15 / 20 / 30 (and a deck's "add more" row gained +15); the
+ route accepts up to 30; and `generateActivities` splits anything above 18 evenly across calls
+ (30 → 15 + 15), merging them into one deck. The first call keeps the existing "add to this
+ deck" contract; later calls are told the title, the types already used and the topics already
+ covered, and a normalised content signature (`activitySignature`) drops a card that repeats an
+ earlier one — the stub in phase 1 returns the same first card in both batches to pin exactly
+ that (29 from two 15-card replies). The route's overall deadline now scales with the number of
+ calls, and the requested/generated pair still travels in the stream's meta. Verified by the
+ phase-1 suite (59 checks: the two calls, the even split, the "already covered" prompt, the
+ merge + dedupe, and a 31-card request answered 400) and the wizard suite (99: the picker
+ offers 30, the wizard sends `count: 30`, and the 30-card deck is stored and opened).
+
111. **The model dropdown was clipped by the card it lived in.** "The model selection in teach it
back is kinda broken at least on desktop": the pickers' desktop panels were
`position: absolute` children of whatever container held their chip. In chat that is the
diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts
index 7acbe6f..60946c1 100644
--- a/app/api/generate/route.ts
+++ b/app/api/generate/route.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai";
+import { activityBatches, extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai";
import { getServerConfig, resolveModelName } from "@/lib/config";
import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
@@ -26,7 +26,8 @@ const requestSchema = z.object({
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(),
difficulty: difficultySchema.default("medium"),
- count: z.number().int().min(4).max(18).optional(),
+ /** One deck; a big count is built in several model calls (lib/ai.ts). */
+ count: z.number().int().min(4).max(30).optional(),
focus: z.string().max(300).optional(),
/** Language the deck must be written in (usually detected by the vision step). */
language: z.string().max(60).optional(),
@@ -129,8 +130,10 @@ export async function POST(request: Request) {
const requestedCount = count ?? config.defaultActivityCount;
const encode = encoder();
const abort = new AbortController();
- // A single hard deadline for the whole pipeline (two sequential model calls).
- const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2);
+ // A single hard deadline for the whole pipeline: the vision step (when there
+ // are photos) plus one model call per activity batch.
+ const modelCalls = activityBatches(requestedCount).length + 1;
+ const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * modelCalls);
const onRequestAbort = () => abort.abort();
if (request.signal.aborted) abort.abort();
else request.signal.addEventListener("abort", onRequestAbort, { once: true });
diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx
index 3d676dd..74d329a 100644
--- a/components/DeckCard.tsx
+++ b/components/DeckCard.tsx
@@ -22,7 +22,7 @@ import { Button, ProgressBar } from "@/components/ui";
type Busy = null | "more" | "regenerate";
-const ADD_OPTIONS = [3, 5, 8];
+const ADD_OPTIONS = [3, 5, 8, 15];
/**
* One deck in the dashboard grid: title, muted metadata, tags, summary and a
diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx
index 33d0ce5..9b2cd8a 100644
--- a/components/UploadScreen.tsx
+++ b/components/UploadScreen.tsx
@@ -83,7 +83,9 @@ const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.Rea
{ value: "hard", labelKey: "upload.hard", icon: , blurbKey: "upload.hardBlurb" },
];
-const COUNT_OPTIONS = [8, 10, 12, 15];
+/** Deck sizes the wizard offers. Above 18 the server builds the deck in
+ * several model calls; 30 is the largest it accepts. */
+const COUNT_OPTIONS = [8, 10, 12, 15, 20, 30];
const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"];
diff --git a/lib/ai.ts b/lib/ai.ts
index 295b9ce..fdbba31 100644
--- a/lib/ai.ts
+++ b/lib/ai.ts
@@ -358,6 +358,59 @@ function validateDeck(parsed: unknown, minimum: number, requested: number) {
return { ok: true as const, value: { title, subject, summary, language, activities } };
}
+/** One model reply's deck shape (validated by validateDeck). */
+type ActivityDeck = {
+ title: string;
+ subject?: string;
+ summary?: string;
+ language?: string;
+ activities: GeneratedActivity[];
+};
+
+/** The largest deck one request may ask for (the wizard offers up to 30). */
+export const MAX_ACTIVITY_COUNT = 30;
+/**
+ * The most activities asked for in one model call. A single reply has to be
+ * valid JSON in one go; past this size replies get truncated or start losing
+ * cards, so a bigger deck is built in several calls and merged.
+ */
+export const ACTIVITY_BATCH_SIZE = 18;
+
+/**
+ * How to split a requested deck across model calls: as few calls as possible,
+ * spread evenly (30 → 15 + 15, 25 → 13 + 12, 8 → 8). Exported so the route can
+ * size its deadline from the number of calls.
+ */
+export function activityBatches(count: number): number[] {
+ const requested = Math.min(Math.max(Math.round(count) || 4, 4), MAX_ACTIVITY_COUNT);
+ const calls = Math.ceil(requested / ACTIVITY_BATCH_SIZE);
+ const base = Math.floor(requested / calls);
+ const extra = requested % calls;
+ return Array.from({ length: calls }, (_, index) => base + (index < extra ? 1 : 0));
+}
+
+/** A card's content, normalised — catches the same idea reappearing in a later
+ * batch (the model cannot see the earlier replies). */
+function activitySignature(activity: GeneratedActivity): string {
+ const normal = (value: string) => value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
+ switch (activity.type) {
+ case "flashcard":
+ return `flashcard:${normal(activity.front)}|${normal(activity.back)}`;
+ case "multiple_choice":
+ return `multiple_choice:${normal(activity.question)}`;
+ case "true_false":
+ return `true_false:${normal(activity.statement)}`;
+ case "fill_blank":
+ return `fill_blank:${normal(activity.sentence)}`;
+ case "matching":
+ return `matching:${normal(activity.instruction)}|${activity.pairs.map((pair) => normal(pair.term)).sort().join(",")}`;
+ case "ordering":
+ return `ordering:${normal(activity.instruction)}|${activity.items.map(normal).sort().join(",")}`;
+ case "short_answer":
+ return `short_answer:${normal(activity.question)}`;
+ }
+}
+
export async function generateActivities(input: {
model: string;
auth: OllamaAuth;
@@ -374,35 +427,72 @@ export async function generateActivities(input: {
deck: { title: string; subject?: string; summary?: string; language?: string; activities: GeneratedActivity[] };
requested: number;
}> {
- const requested = Math.min(Math.max(input.count, 4), 18);
+ const batches = activityBatches(input.count);
+ const requested = batches.reduce((total, size) => total + size, 0);
+ const activities: GeneratedActivity[] = [];
+ /** Cards already produced, by content — a later batch must add new ones. */
+ const seen = new Set();
+ const types = new Set(input.existing?.types ?? []);
+ let deck: ActivityDeck | null = null;
- const { value } = await runJsonStep<{
- title: string;
- subject?: string;
- summary?: string;
- language?: string;
- activities: GeneratedActivity[];
- }>({
- model: input.model,
- auth: input.auth,
- provider: input.provider,
- system: GENERATION_SYSTEM_PROMPT,
- user: buildGenerationUserPrompt({
- notes: input.notes,
- difficulty: input.difficulty,
- count: requested,
- language: input.language,
- existing: input.existing,
- focus: input.focus,
- }),
- validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(requested * 0.5)), requested),
- shapeHint: GENERATION_SHAPE_HINT,
- label: "Building your activities",
- signal: input.signal,
- temperature: 0.7,
- });
+ for (let index = 0; index < batches.length; index += 1) {
+ /* The first call continues an existing deck when the user asked for "more";
+ later calls already have this run's cards, so the prompt tells the model
+ which material and types are taken. */
+ const previous =
+ index === 0
+ ? input.existing
+ : {
+ title: deck?.title ?? input.existing?.title ?? "Your study deck",
+ types: [...types],
+ count: activities.length,
+ topics: [...new Set(activities.map((activity) => activity.topic).filter((topic): topic is string => Boolean(topic)))],
+ };
+ const step = await runJsonStep({
+ model: input.model,
+ auth: input.auth,
+ provider: input.provider,
+ system: GENERATION_SYSTEM_PROMPT,
+ user: buildGenerationUserPrompt({
+ notes: input.notes,
+ difficulty: input.difficulty,
+ count: batches[index],
+ language: input.language ?? deck?.language,
+ existing: previous,
+ focus: input.focus,
+ }),
+ validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(batches[index] * 0.5)), batches[index]),
+ shapeHint: GENERATION_SHAPE_HINT,
+ label: "Building your activities",
+ signal: input.signal,
+ temperature: 0.7,
+ });
+ const value: ActivityDeck = step.value;
- return { deck: value, requested };
+ if (!deck) deck = value;
+ for (const activity of value.activities) {
+ const signature = activitySignature(activity);
+ if (seen.has(signature)) continue;
+ seen.add(signature);
+ activities.push(activity);
+ types.add(activity.type);
+ }
+ if (activities.length >= requested) break;
+ /* Enough cards to stop early: a batch that repeats itself is not going to
+ invent new material, and every extra call costs the user time. */
+ if (value.activities.length === 0) break;
+ }
+
+ return {
+ deck: {
+ title: deck?.title ?? "Your study deck",
+ subject: deck?.subject,
+ summary: deck?.summary,
+ language: deck?.language,
+ activities: activities.slice(0, requested),
+ },
+ requested,
+ };
}
/* ------------------------------------------------------------------ */
diff --git a/lib/config.ts b/lib/config.ts
index 565fd0e..afd980a 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -159,7 +159,7 @@ export function getServerConfig(): ServerConfig {
healthTimeoutMs:
Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000,
think,
- defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 20) : 10,
+ defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 30) : 10,
rateLimits: {
windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000),
generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12),
diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts
index 6635485..c1f50c5 100644
--- a/lib/i18n-dict.ts
+++ b/lib/i18n-dict.ts
@@ -502,7 +502,7 @@ const en: Record = {
"upload.phone.summary": "Ready to freeze",
"upload.phone.summaryDesc": "Check what goes in, then freeze.",
"upload.phone.difficultyHelp": "How deep the questions go.",
- "upload.phone.activitiesHelp": "How many activities to build.",
+ "upload.phone.activitiesHelp": "How many activities to build — bigger decks take longer.",
"upload.phone.folderHelp": "Where the deck lands in your library.",
"upload.nothingAdded": "Nothing added yet",
"upload.summary.files": "Files",
@@ -1663,7 +1663,7 @@ const cs: Record = {
"upload.phone.summary": "Připraveno ke zmrazení",
"upload.phone.summaryDesc": "Zkontroluj, co se použije, a zmraz.",
"upload.phone.difficultyHelp": "Jak hluboko otázky jdou.",
- "upload.phone.activitiesHelp": "Kolik aktivit se vytvoří.",
+ "upload.phone.activitiesHelp": "Kolik aktivit se vytvoří — větší sady trvají déle.",
"upload.phone.folderHelp": "Kam se sada v knihovně zařadí.",
"upload.nothingAdded": "Zatím nic přidáno",
"upload.summary.files": "Soubory",
diff --git a/lib/prompts.ts b/lib/prompts.ts
index bae2b90..cc3edba 100644
--- a/lib/prompts.ts
+++ b/lib/prompts.ts
@@ -138,8 +138,9 @@ export type GenerationRequest = {
count: number;
/** Language the output must be written in (from the vision step or detected). */
language?: string;
- /** Set when adding more activities to an existing deck. */
- existing?: { title: string; types: string[]; count: number };
+ /** Set when adding more activities to an existing deck (or to the part of a
+ * big deck the earlier model calls already built). */
+ existing?: { title: string; types: string[]; count: number; topics?: string[] };
/** Extra instructions from the student (topic hint). */
focus?: string;
};
@@ -159,7 +160,11 @@ export function buildGenerationUserPrompt({
: "LANGUAGE: detect the language of the notes below and write everything in that same language.",
focus ? `The student especially wants to practise: ${focus}` : "",
existing
- ? `This is an addition to the existing deck "${existing.title}" which already has ${existing.count} activities (types used so far: ${existing.types.join(", ") || "none"}). Generate ${count} NEW activities on material that was not already covered, and prefer types that are underrepresented.`
+ ? `This is an addition to the existing deck "${existing.title}" which already has ${existing.count} activities (types used so far: ${existing.types.join(", ") || "none"}).${
+ existing.topics?.length
+ ? ` Topics already covered: ${existing.topics.slice(0, 12).join("; ")} — leave them alone.`
+ : ""
+ } Generate ${count} NEW activities on material that was not already covered, and prefer types that are underrepresented.`
: "",
"",
"=== STUDENT NOTES (the only allowed source of facts) ===",
diff --git a/package.json b/package.json
index 3e1f8fc..4b5d73e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "freezmo",
- "version": "0.8.3",
+ "version": "0.8.4",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
diff --git a/public/sw.js b/public/sw.js
index 7c7182e..56d7c14 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
-const CACHE_VERSION = "freezmo-v105";
+const CACHE_VERSION = "freezmo-v106";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
diff --git a/tests/phase1-test.mjs b/tests/phase1-test.mjs
index f84c5c4..985765c 100644
--- a/tests/phase1-test.mjs
+++ b/tests/phase1-test.mjs
@@ -500,6 +500,116 @@ async function openSettings(page) {
}
}
+/* 9b. big decks: built in batches, merged and deduplicated ----------- */
+{
+ check("batches: app port free", !(await portInUse(3216)));
+ const promptBodies = [];
+ const batchReply = (batch) => ({
+ title: "Big deck",
+ language: "English",
+ summary: "Two batches of the same notes.",
+ activities: Array.from({ length: 15 }, (_, index) => ({
+ type: "flashcard",
+ topic: `Topic ${batch}`,
+ front: `BATCH-${batch}-Q${index}`,
+ back: `BATCH-${batch}-A${index}`,
+ explanation: "One gap in the explanation.",
+ })),
+ });
+ const stub = http.createServer((request, response) => {
+ if (request.url?.startsWith("/api/tags")) {
+ response.writeHead(200, { "Content-Type": "application/json" });
+ response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
+ return;
+ }
+ let raw = "";
+ request.on("data", (chunk) => (raw += chunk));
+ request.on("end", () => {
+ let body = {};
+ try {
+ body = JSON.parse(raw);
+ } catch {
+ /* keep the default */
+ }
+ promptBodies.push(body);
+ const reply = batchReply(promptBodies.length);
+ /* The second call repeats one card on purpose: the merge must not ship
+ the same card twice. */
+ if (promptBodies.length === 2) {
+ reply.activities[0] = {
+ type: "flashcard",
+ topic: "Topic 1",
+ front: "BATCH-1-Q0",
+ back: "BATCH-1-A0",
+ explanation: "One gap in the explanation.",
+ };
+ }
+ response.writeHead(200, { "Content-Type": "application/json" });
+ response.end(JSON.stringify({ message: { content: JSON.stringify(reply) } }));
+ });
+ });
+ await new Promise((resolve) => stub.listen(12003, "127.0.0.1", resolve));
+
+ const app = spawnApp(3216, {
+ OLLAMA_BASE_URL: "http://127.0.0.1:12003",
+ OLLAMA_AUTH: "local",
+ OLLAMA_VERIFY_KEY: "false",
+ });
+
+ try {
+ const up = await waitForHttp("http://127.0.0.1:3216/api/health");
+ check("batches: stub app is up", up);
+ if (up) {
+ const response = await fetch("http://127.0.0.1:3216/api/generate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "The sky is blue because molecules scatter light.", difficulty: "easy", count: 30 }),
+ });
+ const events = (await response.text())
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+ const deck = events.find((event) => event.type === "deck")?.deck;
+ const meta = events.find((event) => event.type === "deck")?.meta;
+ const activities = deck?.activities ?? [];
+ const prompts = promptBodies.map((body) => String(body.messages?.[1]?.content ?? ""));
+ check("batches: a deck of 30 is built in two model calls", promptBodies.length === 2, `calls: ${promptBodies.length}`);
+ check(
+ "batches: each call asks for an even share",
+ prompts[0]?.includes("Create a study deck with 15 activities") && prompts[1]?.includes("Create a study deck with 15 activities"),
+ prompts.map((prompt) => prompt.slice(0, 60)).join(" | "),
+ );
+ check(
+ "batches: the second call is told what is already covered",
+ prompts[1]?.includes("addition to the existing deck") &&
+ prompts[1]?.includes("Topics already covered") &&
+ prompts[1]?.includes("Topic 1"),
+ );
+ check("batches: both batches are merged", activities.length === 29, `activities: ${activities.length}`);
+ check(
+ "batches: a repeated card is shipped once",
+ activities.filter((activity) => activity.front === "BATCH-1-Q0").length === 1,
+ );
+ check(
+ "batches: the deck keeps the first reply's title and the requested count",
+ deck?.title === "Big deck" && meta?.requested === 30 && meta?.generated === 29,
+ JSON.stringify({ title: deck?.title, meta }),
+ );
+
+ const tooMany = await fetch("http://127.0.0.1:3216/api/generate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "The sky is blue.", difficulty: "easy", count: 31 }),
+ });
+ check("batches: more than 30 in one deck → 400", tooMany.status === 400, String(tooMany.status));
+ }
+ } finally {
+ app.stop();
+ stub.close();
+ }
+}
+
/* 10. reports: appended as JSON lines, write-only over HTTP --------- */
{
check("reports: app port free", !(await portInUse(3211)));
diff --git a/tests/wizard-test.mjs b/tests/wizard-test.mjs
index 9e3e6b7..3c61057 100644
--- a/tests/wizard-test.mjs
+++ b/tests/wizard-test.mjs
@@ -305,5 +305,70 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
await context.close();
}
+/* 6. bigger decks: the count picker goes up to 30 -------------------- */
+{
+ const { context, page } = await makePage(browser);
+ await seed(page, { decks: [] });
+ let request = null;
+ const reply = {
+ id: "big",
+ title: "Big deck",
+ subject: "Biology",
+ summary: "Thirty cards.",
+ language: "English",
+ sourceText: "Photosynthesis needs light and water.",
+ difficulty: "medium",
+ activities: Array.from({ length: 30 }, (_, index) => ({
+ type: "flashcard",
+ id: `big-${index}`,
+ xp: 10,
+ topic: "Photosynthesis",
+ front: `Question ${index}`,
+ back: `Answer ${index}`,
+ explanation: "One gap in the explanation.",
+ })),
+ };
+ await page.route("**/api/generate", async (route) => {
+ request = JSON.parse(route.request().postData() ?? "{}");
+ await route.fulfill({
+ status: 200,
+ contentType: "application/x-ndjson",
+ body: JSON.stringify({ type: "deck", deck: reply }) + "\n",
+ });
+ });
+
+ await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(600);
+ await page.getByRole("button", { name: "Paste text instead" }).click();
+ await page.locator("textarea").first().fill("Photosynthesis needs light and water.");
+ await page.getByRole("button", { name: "Next" }).click();
+ await page.waitForTimeout(400);
+
+ const sizes = ["8", "10", "12", "15", "20", "30"];
+ const offered = [];
+ for (const size of sizes) {
+ offered.push((await page.getByRole("button", { name: size, exact: true }).count()) >= 1);
+ }
+ check("count: the picker offers up to 30", offered.every(Boolean), JSON.stringify(sizes.map((size, index) => `${size}:${offered[index]}`)));
+
+ await page.getByRole("button", { name: "30", exact: true }).click();
+ check(
+ "count: 30 reads as the chosen size",
+ (await page.getByRole("button", { name: "30", exact: true }).getAttribute("aria-pressed")) === "true",
+ );
+ await page.getByRole("button", { name: "Next" }).click();
+ await page.waitForTimeout(300);
+ await page.getByRole("button", { name: "Freeze my deck" }).click();
+ await page.waitForTimeout(1600);
+ check("count: the wizard sends the chosen size", request?.count === 30, JSON.stringify({ count: request?.count }));
+ const decks = await page.evaluate(() => JSON.parse(window.localStorage.getItem("freezmo.decks.v1") ?? "[]"));
+ check(
+ "count: a 30-card deck is stored and opened",
+ (decks[0]?.activities ?? []).length === 30 && /\/deck\//.test(page.url()),
+ `activities: ${decks[0]?.activities?.length} url: ${page.url()}`,
+ );
+ await context.close();
+}
+
await browser.close();
finish();