diff --git a/README.md b/README.md
index 6e96d15..fead6c4 100644
--- a/README.md
+++ b/README.md
@@ -1283,11 +1283,13 @@ Things I chose while building, with the reasoning:
50. **One AI disclaimer, only where the model's words are on screen.** A shared `AiDisclaimer`
(copy, style and translations in one place, `common.aiDisclaimer`) sits under the chat composer
— always, since chat is pure model output — next to the generation buttons of the homework and
- deck wizards, under their results, and under AI grading feedback. Stored, already-generated
- content (the deck page, study cards, explanations) deliberately carries no note: the disclaimer
- belongs to the moment of generation, not to browsing, and the empty states stay clean. It is a
- plain `--text-subtle` paragraph (documented at 5.6:1 light / 8.6:1 dark), centred, wrapping to
- two lines on a 360 px phone — not clickable, not dismissable.
+ deck wizards, under their results, and under AI grading feedback. It also rides along with the
+ content itself once it is being consumed: under every card in the study feed and in the review,
+ mistakes and exam sessions, and on their results slides. Navigation surfaces (the library, deck
+ lists, chat history, settings) deliberately carry no note: the disclaimer belongs to the moment
+ of generation and to the card being answered, not to browsing, and the empty states stay clean.
+ It is a plain `--text-subtle` paragraph (documented at 5.6:1 light / 8.6:1 dark), centred,
+ wrapping to two lines on a 360 px phone — not clickable, not dismissable.
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
flow and a 2-step homework flow that share the exact state and handlers with the desktop
diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx
index cd5ddd7..451803f 100644
--- a/components/SessionFeed.tsx
+++ b/components/SessionFeed.tsx
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities";
+import { AiDisclaimer } from "@/components/AiDisclaimer";
import { XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { ProgressBar } from "@/components/ui";
@@ -216,6 +217,7 @@ export function SessionFeed({
isLast={itemIndex === total - 1}
nextLabel={itemIndex === total - 1 ? t("review.seeResults") : undefined}
/>
+
))}
@@ -226,7 +228,12 @@ export function SessionFeed({
>
{finished
- ? renderResults({ results, items, correct, total: attemptedTotal, score })
+ ? (
+ <>
+ {renderResults({ results, items, correct, total: attemptedTotal, score })}
+
+ >
+ )
: (
{t("review.results")}
diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx
index eb50174..811ebb8 100644
--- a/components/StudyFeed.tsx
+++ b/components/StudyFeed.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities";
+import { AiDisclaimer } from "@/components/AiDisclaimer";
import { celebrate } from "@/lib/confetti";
import { ApiError, generateDeck } from "@/lib/client-api";
import { materialiseActivities } from "@/lib/deck-ops";
@@ -326,6 +327,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
onNext={() => goTo(activityIndex + 1)}
/>
+
))}
@@ -350,6 +352,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
regenerating={regenerating}
regenerateError={regenerateError}
/>
+
diff --git a/public/sw.js b/public/sw.js
index 9cb3f33..02404c3 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-v39";
+const CACHE_VERSION = "freezmo-v40";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs
index e867858..cec0e41 100644
--- a/tests/phase2-test.mjs
+++ b/tests/phase2-test.mjs
@@ -119,7 +119,13 @@ const browser = await launchBrowser();
await page.getByRole("button", { name: "Submit answer" }).click();
await page.waitForTimeout(800);
check("grading: AI feedback rendered", await page.getByText("Nice — scattering does the work.").isVisible());
- check("grading: AI disclaimer under the feedback", await page.getByText("Freezmo uses AI and can make mistakes").isVisible());
+ // Scope it to the placement: the disclaimer directly under the graded box.
+ const feedbackDisclaimer = await page.evaluate(() => {
+ const feedback = [...document.querySelectorAll("p")].find((el) => el.textContent.includes("Nice — scattering does the work."));
+ const next = feedback?.closest("div")?.nextElementSibling;
+ return Boolean(next?.textContent?.includes("Freezmo uses AI"));
+ });
+ check("grading: AI disclaimer under the feedback", feedbackDisclaimer);
await context.close();
}
@@ -184,7 +190,6 @@ const browser = await launchBrowser();
title: "Flashcards",
activities: [
{ type: "flashcard", id: "f1", xp: 10, topic: "Sky", front: "Why is the sky blue?", back: "Rayleigh scattering.", explanation: "e" },
- { type: "flashcard", id: "f2", xp: 10, topic: "Sky", front: "Second question", back: "Second answer", explanation: "e" },
],
};
await seed(page, { decks: [flashDeck] });
@@ -201,6 +206,10 @@ const browser = await launchBrowser();
const atRest = await faces();
check("flashcard: front face painted at rest", atRest.front === "visible" && atRest.back === "hidden", JSON.stringify(atRest));
+ check(
+ "feed: AI disclaimer under the card",
+ (await page.getByText("Freezmo uses AI and can make mistakes").count()) >= 1,
+ );
await page.locator("button.group").first().click();
await page.waitForTimeout(900);
const flipped = await faces();
@@ -214,6 +223,19 @@ const browser = await launchBrowser();
await page.waitForTimeout(1000);
const back = await faces();
check("flashcard: flipping back restores the question face", back.front === "visible" && back.back === "hidden", JSON.stringify(back));
+
+ // Answer it and check the results slide carries the disclaimer too.
+ await page.locator("button.group").first().click();
+ await page.waitForTimeout(800);
+ await page.getByRole("button", { name: "I knew it" }).click();
+ await page.waitForTimeout(500);
+ await page.getByRole("button", { name: "See results" }).click();
+ await page.waitForTimeout(800);
+ const resultsDisclaimer = await page.evaluate(() => {
+ const slides = [...document.querySelectorAll("[data-testid='study-feed'] section")];
+ return slides[slides.length - 1]?.textContent?.includes("Freezmo uses AI") ?? false;
+ });
+ check("results: AI disclaimer on the results slide", resultsDisclaimer);
await context.close();
}
@@ -281,6 +303,7 @@ const browser = await launchBrowser();
(await page.getByText("What colour is a clear daytime sky?").count()) === 0 &&
(await page.getByText("What makes the sky blue?").count()) >= 1,
);
+ check("exam: AI disclaimer under the card", (await page.getByText("Freezmo uses AI and can make mistakes").count()) >= 1);
await context.close();
}