AI disclaimer: one shared component, shown where model output appears

"Freezmo uses AI and can make mistakes. Double-check important info."
(plus a natural informal Czech translation), rendered by a shared
AiDisclaimer so the copy, style and translations live in one place.

Placements — only where the model's words reach the screen:
- Chat: under the composer, below the context/model chips — always (chat
  is pure model output, so it also shows in the empty state).
- Make my homework: under the start CTA (desktop) and the mode cards'
  helper line (phone), plus once under the generated result for all three
  modes (make / check / guide).
- New deck wizard: under the summary helper (phone step 3) and the desktop
  CTA, next to Freeze my deck.
- AI grading (short answers): under the graded feedback; study, review and
  exam all share it.

Stored, already-generated content (deck page, study cards, explanations)
deliberately carries no note: the disclaimer belongs to the moment of
generation, not to browsing. Style: --text-subtle (documented at 5.6:1
light / 8.6:1 dark), 11.5px, centred, wraps to two lines at 360px, not
clickable, no icon.

Suites: chat (composer, phone + desktop), wizard (deck step 3 across
360/390 x en/cs x dark/light, homework start cards), phase3 (homework
trigger + result, phone + desktop), phase2 (stubbed grading feedback).
SW -> freezmo-v37. README: decision 50 + chat composer note.

Also fixes the deploy script's test instance: it now execs the server so
the cleanup kill reaps the real PID (the wrapper subshell left orphans
squatting on the test port — three from earlier sessions were found while
verifying this task), and the readiness poll checks the port serves the
NEW commit rather than just answering, so a leftover instance can never
be tested silently.
This commit is contained in:
Martin Dvořák 2026-10-11 22:51:16 +00:00
commit 37a84df462
14 changed files with 151 additions and 34 deletions

View file

@ -780,7 +780,8 @@ have a step explained without leaving the app.
contains math; reasoning models' thinking is hidden behind a collapsible **Thinking** row and contains math; reasoning models' thinking is hidden behind a collapsible **Thinking** row and
never mixed into the answer. Every answer offers copy / regenerate, the last question edit and never mixed into the answer. Every answer offers copy / regenerate, the last question edit and
resend. The composer auto-grows at 16px, sends on Enter on desktop (newline on phones), tracks resend. The composer auto-grows at 16px, sends on Enter on desktop (newline on phones), tracks
the on-screen keyboard through `visualViewport` and respects reduced motion. the on-screen keyboard through `visualViewport`, respects reduced motion and keeps the shared AI
disclaimer under the chips.
- **Privacy.** Notes text and conversations stay in this browser; nothing is stored server-side. - **Privacy.** Notes text and conversations stay in this browser; nothing is stored server-side.
The API key still only ever travels in the `x-ollama-key` header to Freezmo's own route for that The API key still only ever travels in the `x-ollama-key` header to Freezmo's own route for that
single request. single request.
@ -1273,6 +1274,15 @@ Things I chose while building, with the reasoning:
carries `min-w-0` so wide content truncates instead, and the chat suite asserts that the panel carries `min-w-0` so wide content truncates instead, and the chat suite asserts that the panel
stays inside the rail and the main content starts after it, expanded and collapsed. stays inside the rail and the main content starts after it, expanded and collapsed.
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.
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck 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 flow and a 2-step homework flow that share the exact state and handlers with the desktop
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`); cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);

View file

@ -0,0 +1,22 @@
"use client";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
/**
* The faint "AI can make mistakes" note for surfaces that show model output:
* under the chat composer, next to the generation buttons and results of the
* homework and deck wizards, and under AI grading feedback. One component so
* the copy, the style and the translations stay in one place.
*
* --text-subtle is the muted helper token and is documented at 5.6:1 (light)
* and 8.6:1 (dark), so the note stays readable at this size.
*/
export function AiDisclaimer({ className }: { className?: string }) {
const { t } = useI18n();
return (
<p className={cn("text-center text-[11.5px] leading-snug text-[var(--text-subtle)]", className)}>
{t("common.aiDisclaimer")}
</p>
);
}

View file

@ -4,6 +4,7 @@ import { motion, useReducedMotion } from "framer-motion";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api"; import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api";
import { import {
DOCUMENT_ACCEPT_ATTR, DOCUMENT_ACCEPT_ATTR,
@ -1002,6 +1003,7 @@ export function UploadScreen() {
</dl> </dl>
<p className="text-[12.5px] text-[var(--text-subtle)]">{phoneHelper}</p> <p className="text-[12.5px] text-[var(--text-subtle)]">{phoneHelper}</p>
<AiDisclaimer />
</div> </div>
) : null} ) : null}
</motion.div> </motion.div>
@ -1330,6 +1332,7 @@ export function UploadScreen() {
? t("upload.ready") ? t("upload.ready")
: t("upload.addToStart")} : t("upload.addToStart")}
</p> </p>
<AiDisclaimer />
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ import { useI18n } from "@/lib/i18n";
import { modelErrorCopy } from "@/lib/model-errors"; import { modelErrorCopy } from "@/lib/model-errors";
import type { Activity } from "@/lib/schemas"; import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { Button, Spinner } from "@/components/ui"; import { Button, Spinner } from "@/components/ui";
import { ModelErrorActions } from "@/components/ModelErrorActions"; import { ModelErrorActions } from "@/components/ModelErrorActions";
@ -185,6 +186,8 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
) : null} ) : null}
</div> </div>
<AiDisclaimer />
<FeedbackPanel <FeedbackPanel
correct={state.correct} correct={state.correct}
explanation={activity.explanation} explanation={activity.explanation}

View file

@ -2,6 +2,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons"; import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@ -175,6 +176,8 @@ export function Composer({
</span> </span>
) : null} ) : null}
</div> </div>
<AiDisclaimer className="mt-1.5" />
</div> </div>
); );
} }

View file

@ -4,6 +4,7 @@ import { motion, useReducedMotion } from "framer-motion";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { CameraIcon, CheckCircleIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons"; import { CameraIcon, CheckCircleIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { WizardActionBar } from "@/components/WizardActionBar"; import { WizardActionBar } from "@/components/WizardActionBar";
@ -542,6 +543,7 @@ export function HomeworkInput({
<p className="text-center text-[12px] text-[var(--text-subtle)]"> <p className="text-center text-[12px] text-[var(--text-subtle)]">
{online ? t("homework.phone.startHint") : t("offline.generate")} {online ? t("homework.phone.startHint") : t("offline.generate")}
</p> </p>
<AiDisclaimer />
</div> </div>
) : null} ) : null}
</motion.div> </motion.div>
@ -779,6 +781,7 @@ export function HomeworkInput({
<p className="text-center text-[12px] text-[var(--text-subtle)]"> <p className="text-center text-[12px] text-[var(--text-subtle)]">
{canSubmit ? t("homework.source.ready") : t("homework.source.need")} {canSubmit ? t("homework.source.ready") : t("homework.source.need")}
</p> </p>
<AiDisclaimer />
</div> </div>
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} /> <HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { documentsToText } from "@/lib/documents"; import { documentsToText } from "@/lib/documents";
import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api"; import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api";
import { setDeckSeed } from "@/lib/deck-seed"; import { setDeckSeed } from "@/lib/deck-seed";
@ -227,37 +228,36 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
} }
if (view.kind === "record") { if (view.kind === "record") {
if (view.record.mode === "make") {
return (
<HomeworkResult
record={view.record}
onAgain={() => again(view.record)}
onNew={newAssignment}
onToDeck={() => turnIntoDeck(view.record)}
againLoading={againLoading}
/>
);
}
if (view.record.mode === "check") {
return (
<HomeworkCheckResult
record={view.record}
onAgain={() => again(view.record)}
onNew={newAssignment}
onToDeck={() => turnIntoDeck(view.record)}
againLoading={againLoading}
/>
);
}
return ( return (
<HomeworkGuide <div className="space-y-5">
record={view.record} {view.record.mode === "make" ? (
onClose={() => setView({ kind: "start" })} <HomeworkResult
onAgain={() => again(view.record)} record={view.record}
onNew={newAssignment} onAgain={() => again(view.record)}
onToDeck={() => turnIntoDeck(view.record)} onNew={newAssignment}
againLoading={againLoading} onToDeck={() => turnIntoDeck(view.record)}
/> againLoading={againLoading}
/>
) : view.record.mode === "check" ? (
<HomeworkCheckResult
record={view.record}
onAgain={() => again(view.record)}
onNew={newAssignment}
onToDeck={() => turnIntoDeck(view.record)}
againLoading={againLoading}
/>
) : (
<HomeworkGuide
record={view.record}
onClose={() => setView({ kind: "start" })}
onAgain={() => again(view.record)}
onNew={newAssignment}
onToDeck={() => turnIntoDeck(view.record)}
againLoading={againLoading}
/>
)}
<AiDisclaimer />
</div>
); );
} }

View file

@ -90,6 +90,7 @@ const en: Record<string, string> = {
"common.less": "Less", "common.less": "Less",
"common.edit": "Edit", "common.edit": "Edit",
"common.stepOf": "{n} / {total}", "common.stepOf": "{n} / {total}",
"common.aiDisclaimer": "Freezmo uses AI and can make mistakes. Double-check important info.",
/* header ---------------------------------------------------------- */ /* header ---------------------------------------------------------- */
"header.home": "Freezmo home", "header.home": "Freezmo home",
@ -1023,6 +1024,7 @@ const cs: Record<string, string> = {
"common.less": "Méně", "common.less": "Méně",
"common.edit": "Upravit", "common.edit": "Upravit",
"common.stepOf": "{n} / {total}", "common.stepOf": "{n} / {total}",
"common.aiDisclaimer": "Freezmo používá AI a může se mýlit. Důležité informace si raději ověř.",
/* header ---------------------------------------------------------- */ /* header ---------------------------------------------------------- */
"header.home": "Domů — Freezmo", "header.home": "Domů — Freezmo",

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/ */
const CACHE_VERSION = "freezmo-v36"; const CACHE_VERSION = "freezmo-v37";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -126,14 +126,25 @@ if [[ "$RUN_TESTS" == "1" ]]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$PROD/shared/env.local" source "$PROD/shared/env.local"
set +a set +a
( cd "$DIR/server" && PORT="$TEST_PORT" HOSTNAME=127.0.0.1 node server.js >"$DIR/test-server.log" 2>&1 ) & # exec so $! is the server itself, not a wrapper subshell — otherwise the
# cleanup kill only reaps the subshell and the server survives as an orphan
# squatting on the test port.
( cd "$DIR/server" && exec env PORT="$TEST_PORT" HOSTNAME=127.0.0.1 node server.js >"$DIR/test-server.log" 2>&1 ) &
TEST_PID=$! TEST_PID=$!
cleanup_test() { kill "$TEST_PID" >/dev/null 2>&1 || true; wait "$TEST_PID" 2>/dev/null || true; } cleanup_test() { kill "$TEST_PID" >/dev/null 2>&1 || true; wait "$TEST_PID" 2>/dev/null || true; }
trap cleanup_test EXIT trap cleanup_test EXIT
# Ready means the port answers with THIS release's commit: a leftover
# instance from an earlier run would otherwise be tested silently.
ready=0 ready=0
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
if curl -fsS --max-time 2 "http://127.0.0.1:$TEST_PORT/api/healthz" >/dev/null 2>&1; then ready=1; break; fi body="$(curl -fsS --max-time 2 "http://127.0.0.1:$TEST_PORT/api/healthz" 2>/dev/null || true)"
if [[ -n "$body" ]]; then
commit="$(printf '%s' "$body" | grep -o '"commit":"[^"]*"' | cut -d'"' -f4 || true)"
if [[ -n "$commit" && "${commit:0:7}" == "${TARGET:0:7}" ]]; then ready=1; break; fi
cat "$DIR/test-server.log" >&2 || true
fail "port $TEST_PORT answers with ${commit:0:7}, not $SHORT — is another instance still running?"
fi
sleep 1 sleep 1
done done
[[ "$ready" == "1" ]] || { cat "$DIR/test-server.log" >&2; fail "the new release did not start on :$TEST_PORT"; } [[ "$ready" == "1" ]] || { cat "$DIR/test-server.log" >&2; fail "the new release did not start on :$TEST_PORT"; }

View file

@ -288,6 +288,7 @@ try {
check("desktop: collapsed rail also stays inside and clear", collapsedRail !== null && collapsedRail.panelInside && !collapsedRail.overflowing && collapsedRail.contentClear); check("desktop: collapsed rail also stays inside and clear", collapsedRail !== null && collapsedRail.panelInside && !collapsedRail.overflowing && collapsedRail.contentClear);
await desktop.page.getByRole("button", { name: "Expand sidebar" }).click(); await desktop.page.getByRole("button", { name: "Expand sidebar" }).click();
await desktop.page.waitForTimeout(400); await desktop.page.waitForTimeout(400);
check("desktop: AI disclaimer under the composer", await desktop.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
await desktop.context.close(); await desktop.context.close();
const phone = await makePage(browser); const phone = await makePage(browser);
@ -308,6 +309,7 @@ try {
/* 7. UI: streaming + markdown + math + sources ---------------------- */ /* 7. UI: streaming + markdown + math + sources ---------------------- */
check("chat: empty state with suggestions", await phone.page.getByText("Ask your notes anything").isVisible()); check("chat: empty state with suggestions", await phone.page.getByText("Ask your notes anything").isVisible());
check("chat: AI disclaimer under the composer (empty state too)", await phone.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
check("chat: deck suggestion present", (await phone.page.getByRole("button", { name: /Summarize my notes/ }).count()) === 1); check("chat: deck suggestion present", (await phone.page.getByRole("button", { name: /Summarize my notes/ }).count()) === 1);
const context = phone.page.locator(".chat-md").first(); const context = phone.page.locator(".chat-md").first();
await phone.page.getByRole("button", { name: /Summarize my notes/ }).click(); await phone.page.getByRole("button", { name: /Summarize my notes/ }).click();

View file

@ -73,6 +73,56 @@ const browser = await launchBrowser();
await context.close(); await context.close();
} }
/* 1b. AI grading feedback carries the disclaimer (stubbed model call) - */
{
const { context, page } = await makePage(browser);
const shortDeck = {
...DECK,
id: "seed_short",
title: "Short answers",
// Two activities on purpose: with one, grading ends the session and the
// results screen replaces the graded view before the check runs.
activities: [
{
type: "short_answer",
id: "s1",
xp: 10,
topic: "Sky",
question: "Why is the sky blue?",
referenceAnswer: "Rayleigh scattering favours short wavelengths.",
keyPoints: ["scattering", "short wavelengths"],
explanation: "Small molecules scatter blue light more than red.",
},
{
type: "multiple_choice",
id: "s2",
xp: 10,
topic: "Sky",
question: "Which colour scatters most?",
options: ["Blue", "Red", "Green", "Yellow"],
correctIndex: 0,
explanation: "Short wavelengths scatter more.",
},
],
};
await page.route("**/api/grade", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ correct: true, feedback: "Nice — scattering does the work.", missing: [] }),
}),
);
await seed(page, { decks: [shortDeck] });
await page.goto(`${BASE}/deck/seed_short`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
await page.locator("textarea").fill("Because small molecules scatter blue light more.");
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());
await context.close();
}
/* 2. SRS due → review session; stats screen -------------------------- */ /* 2. SRS due → review session; stats screen -------------------------- */
{ {
const now = Date.now(); const now = Date.now();

View file

@ -131,6 +131,7 @@ try {
await phone.page.locator("textarea").first().fill("Solve x + 2 = 4"); await phone.page.locator("textarea").first().fill("Solve x + 2 = 4");
await phone.page.getByRole("button", { name: "Next" }).click(); await phone.page.getByRole("button", { name: "Next" }).click();
await phone.page.waitForTimeout(400); await phone.page.waitForTimeout(400);
check("phone: AI disclaimer near the start cards", await phone.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
await phone.page.getByRole("button", { name: /Check my work/ }).click(); await phone.page.getByRole("button", { name: /Check my work/ }).click();
await phone.page.waitForTimeout(400); await phone.page.waitForTimeout(400);
check("phone: solution field revealed", await phone.page.locator("#homework-solution").isVisible()); check("phone: solution field revealed", await phone.page.locator("#homework-solution").isVisible());
@ -141,6 +142,7 @@ try {
check("phone: what's right listed", await phone.page.getByText("The setup is right.").isVisible()); check("phone: what's right listed", await phone.page.getByText("The setup is right.").isVisible());
check("phone: what needs fixing listed", await phone.page.getByText("4 - 2 is 2, not 3.").isVisible()); check("phone: what needs fixing listed", await phone.page.getByText("4 - 2 is 2, not 3.").isVisible());
check("phone: corrected version shown", await phone.page.getByText("x = 4 - 2").isVisible()); check("phone: corrected version shown", await phone.page.getByText("x = 4 - 2").isVisible());
check("phone: AI disclaimer on the result", await phone.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
await phone.page.getByRole("button", { name: "Shorter" }).click(); await phone.page.getByRole("button", { name: "Shorter" }).click();
await phone.page.waitForTimeout(1500); await phone.page.waitForTimeout(1500);
@ -167,6 +169,7 @@ try {
await desktop.page.getByRole("button", { name: "Check my work", exact: true }).click(); await desktop.page.getByRole("button", { name: "Check my work", exact: true }).click();
await desktop.page.waitForTimeout(2500); await desktop.page.waitForTimeout(2500);
check("desktop: check result renders", await desktop.page.getByText("Corrected version").isVisible()); check("desktop: check result renders", await desktop.page.getByText("Corrected version").isVisible());
check("desktop: AI disclaimer on the result", await desktop.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
await desktop.context.close(); await desktop.context.close();
const guide = await makePage(browser, { width: 390 }); const guide = await makePage(browser, { width: 390 });

View file

@ -63,6 +63,10 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
(await page.getByText(cs ? "Složka" : "Folder", { exact: true }).count()) >= 1, (await page.getByText(cs ? "Složka" : "Folder", { exact: true }).count()) >= 1,
); );
check(`${label}: Freeze enabled with content`, await page.getByRole("button", { name: cs ? "Zmrazit sadu" : "Freeze my deck" }).isEnabled()); check(`${label}: Freeze enabled with content`, await page.getByRole("button", { name: cs ? "Zmrazit sadu" : "Freeze my deck" }).isEnabled());
check(
`${label}: AI disclaimer near the freeze button`,
await page.getByText(cs ? "Freezmo používá AI a může se mýlit" : "Freezmo uses AI and can make mistakes").isVisible(),
);
await page.getByRole("button", { name: cs ? "Upravit" : "Edit" }).first().click(); await page.getByRole("button", { name: cs ? "Upravit" : "Edit" }).first().click();
await page.waitForTimeout(400); await page.waitForTimeout(400);
check(`${label}: Files Edit jumps to step 1`, await page.getByText("1 / 3").isVisible()); check(`${label}: Files Edit jumps to step 1`, await page.getByText("1 / 3").isVisible());
@ -158,6 +162,7 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
await page.locator("textarea").first().fill("Solve x + 2 = 4"); await page.locator("textarea").first().fill("Solve x + 2 = 4");
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
check("homework: AI disclaimer near the start cards", await page.getByText("Freezmo uses AI and can make mistakes").isVisible());
await page.getByRole("button", { name: /Just make it/ }).click(); await page.getByRole("button", { name: /Just make it/ }).click();
await page.waitForTimeout(800); await page.waitForTimeout(800);
check("loading: homework run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0); check("loading: homework run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0);