From 37a84df4623378185fe40a11c130e9c7e79d2851 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Sun, 11 Oct 2026 22:51:16 +0000 Subject: [PATCH] AI disclaimer: one shared component, shown where model output appears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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. --- README.md | 12 +++++- components/AiDisclaimer.tsx | 22 ++++++++++ components/UploadScreen.tsx | 3 ++ components/activities/ShortAnswer.tsx | 3 ++ components/chat/Composer.tsx | 3 ++ components/homework/HomeworkInput.tsx | 3 ++ components/homework/HomeworkScreen.tsx | 60 +++++++++++++------------- lib/i18n-dict.ts | 2 + public/sw.js | 2 +- scripts/deploy.sh | 15 ++++++- tests/chat-test.mjs | 2 + tests/phase2-test.mjs | 50 +++++++++++++++++++++ tests/phase3-test.mjs | 3 ++ tests/wizard-test.mjs | 5 +++ 14 files changed, 151 insertions(+), 34 deletions(-) create mode 100644 components/AiDisclaimer.tsx diff --git a/README.md b/README.md index fce7805..e52d2e4 100644 --- a/README.md +++ b/README.md @@ -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 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 - 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. The API key still only ever travels in the `x-ollama-key` header to Freezmo's own route for that 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 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 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()`); diff --git a/components/AiDisclaimer.tsx b/components/AiDisclaimer.tsx new file mode 100644 index 0000000..17b463e --- /dev/null +++ b/components/AiDisclaimer.tsx @@ -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 ( +

+ {t("common.aiDisclaimer")} +

+ ); +} diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 6fd6bc3..57914bb 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -4,6 +4,7 @@ import { motion, useReducedMotion } from "framer-motion"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { AiDisclaimer } from "@/components/AiDisclaimer"; import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api"; import { DOCUMENT_ACCEPT_ATTR, @@ -1002,6 +1003,7 @@ export function UploadScreen() {

{phoneHelper}

+ ) : null} @@ -1330,6 +1332,7 @@ export function UploadScreen() { ? t("upload.ready") : t("upload.addToStart")}

+ diff --git a/components/activities/ShortAnswer.tsx b/components/activities/ShortAnswer.tsx index 9aa4326..c986db9 100644 --- a/components/activities/ShortAnswer.tsx +++ b/components/activities/ShortAnswer.tsx @@ -7,6 +7,7 @@ import { useI18n } from "@/lib/i18n"; import { modelErrorCopy } from "@/lib/model-errors"; import type { Activity } from "@/lib/schemas"; import { cn } from "@/lib/utils"; +import { AiDisclaimer } from "@/components/AiDisclaimer"; import { Button, Spinner } from "@/components/ui"; import { ModelErrorActions } from "@/components/ModelErrorActions"; @@ -185,6 +186,8 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is ) : null} + + ) : null} + + ); } diff --git a/components/homework/HomeworkInput.tsx b/components/homework/HomeworkInput.tsx index a2c5e4a..8dc5307 100644 --- a/components/homework/HomeworkInput.tsx +++ b/components/homework/HomeworkInput.tsx @@ -4,6 +4,7 @@ import { motion, useReducedMotion } from "framer-motion"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { AiDisclaimer } from "@/components/AiDisclaimer"; import { CameraIcon, CheckCircleIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons"; import { Button } from "@/components/ui"; import { WizardActionBar } from "@/components/WizardActionBar"; @@ -542,6 +543,7 @@ export function HomeworkInput({

{online ? t("homework.phone.startHint") : t("offline.generate")}

+ ) : null} @@ -779,6 +781,7 @@ export function HomeworkInput({

{canSubmit ? t("homework.source.ready") : t("homework.source.need")}

+ diff --git a/components/homework/HomeworkScreen.tsx b/components/homework/HomeworkScreen.tsx index 8c30725..147cc10 100644 --- a/components/homework/HomeworkScreen.tsx +++ b/components/homework/HomeworkScreen.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; +import { AiDisclaimer } from "@/components/AiDisclaimer"; import { documentsToText } from "@/lib/documents"; import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api"; import { setDeckSeed } from "@/lib/deck-seed"; @@ -227,37 +228,36 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string } } if (view.kind === "record") { - if (view.record.mode === "make") { - return ( - again(view.record)} - onNew={newAssignment} - onToDeck={() => turnIntoDeck(view.record)} - againLoading={againLoading} - /> - ); - } - if (view.record.mode === "check") { - return ( - again(view.record)} - onNew={newAssignment} - onToDeck={() => turnIntoDeck(view.record)} - againLoading={againLoading} - /> - ); - } return ( - setView({ kind: "start" })} - onAgain={() => again(view.record)} - onNew={newAssignment} - onToDeck={() => turnIntoDeck(view.record)} - againLoading={againLoading} - /> +
+ {view.record.mode === "make" ? ( + again(view.record)} + onNew={newAssignment} + onToDeck={() => turnIntoDeck(view.record)} + againLoading={againLoading} + /> + ) : view.record.mode === "check" ? ( + again(view.record)} + onNew={newAssignment} + onToDeck={() => turnIntoDeck(view.record)} + againLoading={againLoading} + /> + ) : ( + setView({ kind: "start" })} + onAgain={() => again(view.record)} + onNew={newAssignment} + onToDeck={() => turnIntoDeck(view.record)} + againLoading={againLoading} + /> + )} + +
); } diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 261aae4..c186de8 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -90,6 +90,7 @@ const en: Record = { "common.less": "Less", "common.edit": "Edit", "common.stepOf": "{n} / {total}", + "common.aiDisclaimer": "Freezmo uses AI and can make mistakes. Double-check important info.", /* header ---------------------------------------------------------- */ "header.home": "Freezmo home", @@ -1023,6 +1024,7 @@ const cs: Record = { "common.less": "Méně", "common.edit": "Upravit", "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.home": "Domů — Freezmo", diff --git a/public/sw.js b/public/sw.js index d02a97e..1e9f01e 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-v36"; +const CACHE_VERSION = "freezmo-v37"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b19b56e..7db0a49 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -126,14 +126,25 @@ if [[ "$RUN_TESTS" == "1" ]]; then # shellcheck disable=SC1090 source "$PROD/shared/env.local" 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=$! cleanup_test() { kill "$TEST_PID" >/dev/null 2>&1 || true; wait "$TEST_PID" 2>/dev/null || true; } 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 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 done [[ "$ready" == "1" ]] || { cat "$DIR/test-server.log" >&2; fail "the new release did not start on :$TEST_PORT"; } diff --git a/tests/chat-test.mjs b/tests/chat-test.mjs index c39cc3a..984369d 100644 --- a/tests/chat-test.mjs +++ b/tests/chat-test.mjs @@ -288,6 +288,7 @@ try { 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.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(); const phone = await makePage(browser); @@ -308,6 +309,7 @@ try { /* 7. UI: streaming + markdown + math + sources ---------------------- */ 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); const context = phone.page.locator(".chat-md").first(); await phone.page.getByRole("button", { name: /Summarize my notes/ }).click(); diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs index 45931e1..be2f49e 100644 --- a/tests/phase2-test.mjs +++ b/tests/phase2-test.mjs @@ -73,6 +73,56 @@ const browser = await launchBrowser(); 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 -------------------------- */ { const now = Date.now(); diff --git a/tests/phase3-test.mjs b/tests/phase3-test.mjs index 28aefb4..474987d 100644 --- a/tests/phase3-test.mjs +++ b/tests/phase3-test.mjs @@ -131,6 +131,7 @@ try { await phone.page.locator("textarea").first().fill("Solve x + 2 = 4"); await phone.page.getByRole("button", { name: "Next" }).click(); 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.waitForTimeout(400); 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 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: 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.waitForTimeout(1500); @@ -167,6 +169,7 @@ try { await desktop.page.getByRole("button", { name: "Check my work", exact: true }).click(); await desktop.page.waitForTimeout(2500); 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(); const guide = await makePage(browser, { width: 390 }); diff --git a/tests/wizard-test.mjs b/tests/wizard-test.mjs index 01e0870..8dc6c7e 100644 --- a/tests/wizard-test.mjs +++ b/tests/wizard-test.mjs @@ -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, ); 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.waitForTimeout(400); 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.getByRole("button", { name: "Next" }).click(); 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.waitForTimeout(800); check("loading: homework run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0);