From 796e2e9fe9d3c44d63fc9135ee41bc4546873f69 Mon Sep 17 00:00:00 2001
From: Martin Dvorak
Date: Thu, 15 Oct 2026 05:53:47 +0000
Subject: [PATCH] Study cards: from-your-notes, read aloud, timed runs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From your notes: the generator now stores a verbatim source quote with every
activity (schema + prompt + a server-side clamp so a chatty model cannot drop a
card), and the feedback panel shows the sentence that carries it with the quote
highlighted green. Decks made before the change, and quotes the model trimmed,
fall back to a local IDF-weighted match over the saved notes text
(lib/notes-source.ts): nothing is shown unless at least two distinct terms
land. The passage hides in exam mode with the other reveals.
Read aloud: a Web Speech API speaker control on the text a learner reads
(flashcard faces, questions, statements, sentences, instructions) via
lib/speech.ts and components/SpeakButton.tsx — one utterance app-wide, a voice
matched to the deck's language, and no button at all where the browser has no
speech synthesis. It used to sit inside the flashcard's flip button (nested
buttons, tapping it flipped the card); it now lives next to the hint row.
Timed runs: the feed chrome carries a timer chip (off by default) that gives
each card 30 seconds; a card that runs out is recorded as a miss through the
same path as a wrong answer (history + SRS agree), shows a toast, and the feed
moves straight on with a fresh budget. handleAnswered ignores a second result
per card, so a timeout plus a manual answer cannot record twice.
The generator's type spec now names what each type suits (language decks lean
on flashcards/matching/fill blank, ordering needs a real sequence, short answer
reasoning), so the deck-aware guidance covers all seven types.
New activities-test suite (24 checks: quote path, local path, the green mark,
the speech stub, the timed run on Playwright's fake clock), registered in
run-all; phase2 pins that the notes passage stays hidden in exam mode; en+cs
keys for every new string; no new dependencies. Version 0.6.1, SW v83.
---
README.md | 46 +++-
components/SpeakButton.tsx | 51 ++++
components/StudyFeed.tsx | 69 ++++-
components/activities/FillBlank.tsx | 7 +-
components/activities/Flashcard.tsx | 9 +-
components/activities/Matching.tsx | 9 +-
components/activities/MultipleChoice.tsx | 8 +-
components/activities/NotesSource.tsx | 55 ++++
components/activities/Ordering.tsx | 9 +-
components/activities/ShortAnswer.tsx | 7 +-
components/activities/TrueFalse.tsx | 7 +-
components/activities/context.tsx | 36 +++
components/activities/index.tsx | 1 +
components/activities/shared.tsx | 27 +-
components/icons.tsx | 30 +++
lib/ai.ts | 32 ++-
lib/i18n-dict.ts | 12 +
lib/notes-source.ts | 309 +++++++++++++++++++++++
lib/prompts.ts | 19 +-
lib/schemas.ts | 6 +
lib/speech.ts | 150 +++++++++++
package.json | 2 +-
public/sw.js | 2 +-
tests/activities-test.mjs | 209 +++++++++++++++
tests/phase2-test.mjs | 6 +
tests/run-all.mjs | 1 +
26 files changed, 1068 insertions(+), 51 deletions(-)
create mode 100644 components/SpeakButton.tsx
create mode 100644 components/activities/NotesSource.tsx
create mode 100644 components/activities/context.tsx
create mode 100644 lib/notes-source.ts
create mode 100644 lib/speech.ts
create mode 100644 tests/activities-test.mjs
diff --git a/README.md b/README.md
index eca681d..6c619c4 100644
--- a/README.md
+++ b/README.md
@@ -172,6 +172,13 @@ no database, no data leaving your machine except the requests you send to Ollama
| `ordering` | Drag with the mouse/finger, or use ▲▼ buttons, then check |
| `short_answer` | Typed answer graded by the AI via `/api/grade`, with a self-check fallback |
+Every card carries the extras that make it studyable: a **speaker** control (Web Speech API,
+the deck's language, on the text you are meant to read — flashcard faces, questions, statements,
+instructions), a **“From your notes”** passage after you answer (the generator stores a verbatim
+quote with each activity; decks made before that get a local, IDF-weighted sentence match over
+the saved notes) with the matching words highlighted green, and the feed chrome's **⏱ timed run**
+(off by default — 30 seconds a card, running out counts as a miss and the feed moves on).
+
**First run**
- A full-screen **API-key gate** replaces the app until a key is saved (in `user-key` mode): one
@@ -846,11 +853,13 @@ app/
components/
activities/ one component per activity type + shared shell
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
- Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
+ Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx context.tsx
+ NotesSource.tsx index.tsx
homework/ make-my-homework screens (input, result, guide feed, history)
chat/ chat screen, composer, message rows, markdown + math, pickers
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
+ SpeakButton.tsx (read-aloud control for study cards)
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
lib/
ollama.ts server-only HTTP client (timeouts, auth, error mapping)
@@ -871,6 +880,8 @@ lib/
homework.ts localStorage homework results + solution assembly
use-notes-intake.ts shared client-side photo/document intake hook
clipboard.ts copy helper with a non-secure-context fallback
+ notes-source.ts "from your notes": quote lookup + local sentence matching
+ speech.ts Web Speech API wrapper (one utterance, deck language)
image.ts client-side validation, resize and compression
client-api.ts browser client for the API routes (NDJSON reader)
chat.ts chat types + last-model memory
@@ -1423,6 +1434,39 @@ Things I chose while building, with the reasoning:
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
"cut off next to the sidebar".
+92. **Study cards: where the answer came from, read aloud, and timed runs.** Three additions to
+ the study feed, all local and dependency-free.
+ - **From your notes.** The generator now returns `source` with every activity: the verbatim
+ words from the notes that prove the answer (max 25 words; `clampQuote` in `lib/ai.ts` trims
+ anything longer to a clean cut, so a chatty model cannot drop a card). After an answer the
+ feedback panel shows the sentence that carries it with the quote highlighted green
+ (`mark`). Decks made before the change — and quotes the model trimmed — fall back to a
+ local, IDF-weighted match over the stored notes text (`lib/notes-source.ts`): a query from
+ the activity's own wording, sentence scoring where rare words weigh more, word-boundary
+ highlighting, and nothing shown unless at least two distinct terms land (a random sentence
+ would be worse than silence). It disappears in exam mode with the other reveals.
+ - **Read aloud.** A speaker control on the text a learner is meant to read (both flashcard
+ faces, the visible one only; questions, statements, sentences, instructions) on the Web
+ Speech API (`lib/speech.ts`, `components/SpeakButton.tsx`): one utterance at a time
+ app-wide, a voice matched to the deck's language (BCP-47 table, interface locale as the
+ fallback, 0.95 rate), and no button at all where the browser has no speech synthesis.
+ Tapping the speaker used to flip the flashcard — it sat inside the flip button (nested
+ buttons) — so it now lives next to the hint row, outside the flip target.
+ - **Timed runs.** The feed chrome carries a timer chip (off by default): on, each card gets
+ 30 seconds (`TIMED_CARD_SECONDS`), the chip counts down, and a card that runs out is
+ recorded as a miss through the same path as a wrong answer (history + SRS agree), shows a
+ toast, and the feed moves straight on to the next card with a fresh budget. The auto-advance
+ is deliberately not deferred: recording the miss re-renders the feed and would cancel a
+ queued scroll with its effect cleanup. `handleAnswered` now ignores a second result for the
+ same card, so a timeout followed by a manual answer cannot write two history entries.
+ - The generator's type spec now names what each type suits (flashcards, matching and fill in
+ the blank for language decks; ordering for real sequences; short answer for reasoning), so
+ the deck-aware guidance covers all seven types, not just ordering.
+ New suite `activities-test` (24 checks: quote path, local path, the green mark, the speech
+ stub — real `SpeechSynthesisUtterance`, recording `speak`/`cancel` — and the timed run with
+ Playwright's fake clock), registered in run-all; the exam suite pins that the notes passage
+ stays hidden with the other reveals; en + cs keys for every new string; no new dependencies.
+
91. **The chat chrome floats: one gutter, one radius scale, one glass recipe.** The header and
the composer were full-bleed bands inside the main pane — flush to the viewport's top, right
and bottom edges, with the composer's bottom corners cropped off-screen — while the rail
diff --git a/components/SpeakButton.tsx b/components/SpeakButton.tsx
new file mode 100644
index 0000000..30c438d
--- /dev/null
+++ b/components/SpeakButton.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useId, useSyncExternalStore } from "react";
+
+import { SpeakerIcon, StopIcon } from "@/components/icons";
+import { useI18n, useLocale } from "@/lib/i18n";
+import { isSpeaking, speak, speechAvailable, speechLocale, stopSpeaking, subscribeSpeech } from "@/lib/speech";
+import { cn } from "@/lib/utils";
+
+/**
+ * Small round "read this aloud" control, placed next to the text a learner is
+ * meant to read (a flashcard face, a question, a statement, an instruction).
+ * It renders nothing where the browser has no speech synthesis, so no card
+ * carries a dead button, and it flips to a stop control while it is the
+ * utterance playing.
+ */
+export function SpeakButton({
+ text,
+ language,
+ className,
+}: {
+ text: string;
+ /** The deck's language, so the voice matches the text. */
+ language?: string;
+ className?: string;
+}) {
+ const { t } = useI18n();
+ const locale = useLocale();
+ const id = useId();
+ const speaking = useSyncExternalStore(
+ subscribeSpeech,
+ () => isSpeaking(id),
+ () => false,
+ );
+ if (!speechAvailable()) return null;
+ return (
+ (speaking ? stopSpeaking() : speak(id, text, speechLocale(language, locale)))}
+ aria-label={speaking ? t("activity.stopSpeaking") : t("activity.speak")}
+ title={speaking ? t("activity.stopSpeaking") : t("activity.speak")}
+ className={cn(
+ "touch-target grid size-9 shrink-0 place-items-center rounded-full border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
+ speaking && "border-accent-400/70 text-accent-600 dark:text-accent-200",
+ className,
+ )}
+ >
+ {speaking ? : }
+
+ );
+}
diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx
index 9ca8b2e..830ac40 100644
--- a/components/StudyFeed.tsx
+++ b/components/StudyFeed.tsx
@@ -4,7 +4,7 @@ import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { ActivityRenderer } from "@/components/activities";
+import { ActivityRenderer, NotesTextProvider } from "@/components/activities";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { celebrate } from "@/lib/confetti";
import { ApiError, generateDeck } from "@/lib/client-api";
@@ -17,7 +17,8 @@ import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt, upsertDeck } from "@/lib/storage";
import { useWakeLock } from "@/lib/use-wake-lock";
import { cn } from "@/lib/utils";
-import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
+import { showToast } from "@/lib/toast";
+import { BoltIcon, ClockIcon, FlameIcon, XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { Button, ProgressBar } from "@/components/ui";
@@ -25,6 +26,9 @@ import { ResultsScreen } from "./ResultsScreen";
export type AttemptResult = { correct: boolean; userAnswer?: string };
+/** Seconds a card is given in a timed run (the chrome shows the countdown). */
+export const TIMED_CARD_SECONDS = 30;
+
export function StudyFeed({ deck }: { deck: Deck }) {
const { t } = useI18n();
/* Keep the screen awake for the whole study session (feature-detected). */
@@ -45,6 +49,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const [regenerateError, setRegenerateError] = useState(null);
/** Bumped on every restart so the activity components remount fresh. */
const [runId, setRunId] = useState(0);
+ /** Timed run: each card gets TIMED_CARD_SECONDS, a card that runs out counts
+ * as a miss. Off by default — the toggle sits in the chrome. */
+ const [timed, setTimed] = useState(false);
+ const [remaining, setRemaining] = useState(null);
const scrollRef = useRef(null);
const recordedRef = useRef(false);
@@ -135,8 +143,9 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const handleAnswered = useCallback(
(activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => {
+ if (results[activity.id]) return; // first answer counts, recorded once
setResults((current) => {
- if (current[activity.id]) return current; // first answer counts
+ if (current[activity.id]) return current;
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
});
// Every answer feeds the history, the SRS schedule and the mistakes set.
@@ -160,9 +169,37 @@ export function StudyFeed({ deck }: { deck: Deck }) {
setStreak(0);
}
},
- [currentDeck.id, currentDeck.title, streak],
+ [currentDeck.id, currentDeck.title, results, streak],
);
+ /* Timed runs ---------------------------------------------------------- */
+ const timedActivity = index < total ? queue[index] : null;
+ const timedAnswered = timedActivity ? Boolean(results[timedActivity.id]) : true;
+
+ useEffect(() => {
+ if (!timed || !timedActivity || timedAnswered) {
+ setRemaining(null);
+ return;
+ }
+ setRemaining(TIMED_CARD_SECONDS);
+ const timer = window.setInterval(() => {
+ setRemaining((value) => (value === null ? null : Math.max(0, value - 1)));
+ }, 1000);
+ return () => window.clearInterval(timer);
+ }, [timed, timedActivity, timedAnswered, runId]);
+
+ /* Ran out: the card counts as a miss (the same path as a wrong answer, so
+ history and the SRS schedule see it), the learner is told, and the feed
+ moves on. */
+ useEffect(() => {
+ if (!timed || remaining !== 0 || !timedActivity || timedAnswered) return;
+ handleAnswered(timedActivity, false, { userAnswer: t("feed.timedOut") });
+ showToast({ message: t("feed.timedOut") });
+ // Straight away, not on a timer: recording the miss re-renders (and
+ // re-runs this effect), and a deferred scroll would be cleaned up with it.
+ goTo(index + 1);
+ }, [timed, remaining, timedActivity, timedAnswered, handleAnswered, goTo, index, t]);
+
const missed = useMemo(
() => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]?.correct),
[queue, results, skipped],
@@ -244,7 +281,10 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const progressMax = slideCount;
return (
-
+ // The deck's own notes travel with the feed: after an answer the feedback
+ // panel points back into them (NotesSource).
+
+
{/* Chrome ---------------------------------------------------- */}
{/* One pill: round close button, title + position above the bar, and
the streak/xp chips at the other end. Everything sits on the pill's
@@ -279,6 +319,22 @@ export function StudyFeed({ deck }: { deck: Deck }) {
+ setTimed((value) => !value)}
+ aria-label={timed ? t("feed.timedOn") : t("feed.timed")}
+ title={timed ? t("feed.timedOn") : t("feed.timed")}
+ className={cn(
+ "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
+ timed ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]",
+ )}
+ >
+
+ {timed && remaining !== null ? `${remaining}s` : `${TIMED_CARD_SECONDS}s`}
+
-
+
+
);
}
diff --git a/components/activities/FillBlank.tsx b/components/activities/FillBlank.tsx
index d71d4db..40a6f57 100644
--- a/components/activities/FillBlank.tsx
+++ b/components/activities/FillBlank.tsx
@@ -6,6 +6,7 @@ import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
+import { SpeakButton } from "@/components/SpeakButton";
import {
ActivityHeader,
@@ -67,7 +68,7 @@ export function isFillBlankCorrect(userValue: string, answer: string): boolean {
});
}
-export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
+export function FillBlank({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
const { t } = useI18n();
const [value, setValue] = useState("");
const [chosen, setChosen] = useState(null);
@@ -141,6 +142,9 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLa
)}
{after}
+
+
+
{activity.options ? (
@@ -181,6 +185,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLa
{result !== null ? (
;
-export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
+export function Flashcard({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
const { t } = useI18n();
const reduceMotion = useReducedMotion();
const [flipped, setFlipped] = useState(false);
@@ -46,7 +47,10 @@ export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: A
return (
<>
- {flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}
+
+ {flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}
+
+
;
-export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
+export function Matching({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
const { t, plural } = useI18n();
const terms = useMemo(
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
@@ -67,7 +68,10 @@ export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
}
/>
- {activity.instruction}
+
+
{activity.instruction}
+
+
{done
? t("matching.allMatched")
@@ -133,6 +137,7 @@ export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
{complete ? (
;
export function MultipleChoice({
activity,
active,
+ language,
onAnswered,
onNext,
isLast,
@@ -62,7 +64,10 @@ export function MultipleChoice({
<>
- {activity.question}
+
+ {activity.question}
+
+
@@ -83,6 +88,7 @@ export function MultipleChoice({
{answered ? (
(noHints ? null : notesSource(notes, activity)), [noHints, notes, activity]);
+ if (!match) return null;
+ const pieces = highlightSegments(match.text, match.spans);
+ return (
+
+
+
+ {t("activity.source")}
+
+
+ {pieces.map((piece, index) =>
+ piece.hit ? (
+
+ {piece.text}
+
+ ) : (
+ {piece.text}
+ ),
+ )}
+
+
+ );
+}
diff --git a/components/activities/Ordering.tsx b/components/activities/Ordering.tsx
index b56b2ae..032fd1f 100644
--- a/components/activities/Ordering.tsx
+++ b/components/activities/Ordering.tsx
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn, shuffle } from "@/lib/utils";
+import { SpeakButton } from "@/components/SpeakButton";
import { Button } from "@/components/ui";
import {
@@ -18,7 +19,7 @@ import {
type OrderingActivity = Extract;
-export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
+export function Ordering({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
const { t } = useI18n();
const initial = useMemo(() => {
const out = shuffle(activity.items);
@@ -54,7 +55,10 @@ export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
<>
- {activity.instruction}
+
+
{activity.instruction}
+
+
{checked ? t("ordering.correctOrder") : t("ordering.hint")}
@@ -128,6 +132,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
) : (
- {activity.question}
+
+ {activity.question}
+
+
@@ -186,6 +190,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
;
-export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
+export function TrueFalse({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) {
const { t } = useI18n();
const [result, setResult] = useState(null);
const x = useMotionValue(0);
@@ -65,6 +66,9 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: A
“{activity.statement}”
+
+
+
@@ -80,6 +84,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: A
) : (
(undefined);
+
+export function NotesTextProvider({ value, children }: { value?: string; children: React.ReactNode }) {
+ return {children} ;
+}
+
+export function useNotesText(): string | undefined {
+ return useContext(NotesTextContext);
+}
+
+const NoHintsContext = createContext(false);
+
+export function NoHintsProvider({ value, children }: { value: boolean; children: React.ReactNode }) {
+ return {children} ;
+}
+
+export function useNoHints(): boolean {
+ return useContext(NoHintsContext);
+}
diff --git a/components/activities/index.tsx b/components/activities/index.tsx
index 8124fda..95aa259 100644
--- a/components/activities/index.tsx
+++ b/components/activities/index.tsx
@@ -12,6 +12,7 @@ import { NoHintsProvider, type ActivityComponentProps } from "./shared";
export type { ActivityComponentProps } from "./shared";
export { TYPE_META } from "./shared";
+export { NotesTextProvider, useNotesText } from "./context";
/**
* Renders an activity from JSON based on its `type` field.
diff --git a/components/activities/shared.tsx b/components/activities/shared.tsx
index 2cfa598..7ce5a1e 100644
--- a/components/activities/shared.tsx
+++ b/components/activities/shared.tsx
@@ -2,13 +2,17 @@
import { motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
-import { createContext, useContext } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
+import { useNoHints } from "./context";
+import { NotesSource } from "./NotesSource";
+
+export { NoHintsProvider, useNoHints, NotesTextProvider, useNotesText } from "./context";
+export { NotesSource } from "./NotesSource";
export type ActivityComponentProps = {
activity: A;
@@ -150,6 +154,7 @@ export function FeedbackPanel({
nextLabel,
headline,
detail,
+ activity,
children,
}: {
correct: boolean;
@@ -161,6 +166,8 @@ export function FeedbackPanel({
nextLabel?: string;
headline?: string;
detail?: React.ReactNode;
+ /** The card being answered, so the panel can point back into the notes. */
+ activity?: Activity;
children?: React.ReactNode;
}) {
const { t } = useI18n();
@@ -210,6 +217,8 @@ export function FeedbackPanel({
{explanation}
+ {activity ? : null}
+
{children}
@@ -230,17 +239,5 @@ export function Hint({ children }: { children: React.ReactNode }) {
return
{children}
;
}
-/**
- * Exam mode sets this through ActivityRenderer's `noHints` prop so the
- * affordances that give the answer away disappear without touching every
- * activity component.
- */
-const NoHintsContext = createContext(false);
-
-export function NoHintsProvider({ value, children }: { value: boolean; children: React.ReactNode }) {
- return
{children} ;
-}
-
-export function useNoHints(): boolean {
- return useContext(NoHintsContext);
-}
+/* The no-hints / notes-text contexts live in ./context: NotesSource reads them
+ while this file renders NotesSource, so keeping them apart avoids a cycle. */
diff --git a/components/icons.tsx b/components/icons.tsx
index 07c64ac..ee8c515 100644
--- a/components/icons.tsx
+++ b/components/icons.tsx
@@ -399,3 +399,33 @@ export function ChromeIcon({ className }: IconProps) {
);
}
+
+/** Read-aloud control (study cards); the waves light up while speaking. */
+export function SpeakerIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+/** Timed runs. */
+export function ClockIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+/** "From your notes" — a page with a folded corner and text lines. */
+export function NotesIcon({ className }: IconProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/lib/ai.ts b/lib/ai.ts
index 3518172..ed86f82 100644
--- a/lib/ai.ts
+++ b/lib/ai.ts
@@ -630,24 +630,40 @@ export async function refineHomeworkText(input: {
* deduping — a shorter list would fail validation on the next read and take
* the whole deck down with it.
*/
+/**
+ * The "source" quote is shown verbatim (and matched against the stored notes),
+ * so a model that ignores the 25-word rule gets clamped to a clean, short
+ * quote: whitespace collapsed, cut at a sentence or word boundary.
+ */
+function clampQuote(quote: string): string {
+ const clean = quote.replace(/\s+/g, " ").trim();
+ if (clean.length <= 240) return clean;
+ const cut = clean.slice(0, 240);
+ const boundary = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
+ if (boundary > 80) return cut.slice(0, boundary + 1);
+ const space = cut.lastIndexOf(" ");
+ return space > 40 ? cut.slice(0, space) : cut;
+}
+
function sanitiseActivity(activity: GeneratedActivity): GeneratedActivity {
- switch (activity.type) {
+ const sanitised = activity.source ? { ...activity, source: clampQuote(activity.source) } : activity;
+ switch (sanitised.type) {
case "ordering": {
- const items = dedupeBy(activity.items, (item) => item);
- return items.length === activity.items.length || items.length < 3 ? activity : { ...activity, items };
+ const items = dedupeBy(sanitised.items, (item) => item);
+ return items.length === sanitised.items.length || items.length < 3 ? sanitised : { ...sanitised, items };
}
case "fill_blank": {
- if (/_{2,}/.test(activity.sentence)) return activity;
- return { ...activity, sentence: `${activity.sentence.trim()} ____` };
+ if (/_{2,}/.test(sanitised.sentence)) return sanitised;
+ return { ...sanitised, sentence: `${sanitised.sentence.trim()} ____` };
}
case "matching": {
// Terms first, then definitions: one definition per term, or the learner
// cannot tell the pairs apart.
- const pairs = dedupeBy(dedupeBy(activity.pairs, (pair) => pair.term), (pair) => pair.definition);
- return pairs.length === activity.pairs.length || pairs.length < 3 ? activity : { ...activity, pairs };
+ const pairs = dedupeBy(dedupeBy(sanitised.pairs, (pair) => pair.term), (pair) => pair.definition);
+ return pairs.length === sanitised.pairs.length || pairs.length < 3 ? sanitised : { ...sanitised, pairs };
}
default:
- return activity;
+ return sanitised;
}
}
diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts
index 1eef099..7fc797a 100644
--- a/lib/i18n-dict.ts
+++ b/lib/i18n-dict.ts
@@ -535,6 +535,9 @@ const en: Record
= {
"feed.results": "Results",
"feed.report": "Report question",
"feed.reported": "Question reported — it won't show up again.",
+ "feed.timed": "Timed run · 30s per card",
+ "feed.timedOn": "Timed run on — a card that runs out counts as a miss",
+ "feed.timedOut": "Time's up — the card counts as a miss.",
/* results --------------------------------------------------------- */
"results.perfect": "Perfect freeze! 🧊",
@@ -656,6 +659,9 @@ const en: Record = {
"activity.next": "Next",
"activity.correctAnswer": "Correct answer:",
"activity.unsupported": "Unsupported activity: {json}",
+ "activity.source": "From your notes",
+ "activity.speak": "Read aloud",
+ "activity.stopSpeaking": "Stop reading",
/* flashcard ------------------------------------------------------- */
"flashcard.tapToFlip": "Tap the card to flip it.",
@@ -1524,6 +1530,9 @@ const cs: Record = {
"feed.prev": "Předchozí aktivita",
"feed.next": "Další aktivita",
"feed.results": "Výsledky",
+ "feed.timed": "Kolo na čas · 30 s na kartu",
+ "feed.timedOn": "Na čas — kartě, které vyprší čas, se počítá jako chyba",
+ "feed.timedOut": "Čas vypršel — karta se počítá jako chyba.",
"feed.report": "Nahlásit otázku",
"feed.reported": "Otázka nahlášena — už se neukáže.",
@@ -1647,6 +1656,9 @@ const cs: Record = {
"activity.next": "Další",
"activity.correctAnswer": "Správná odpověď:",
"activity.unsupported": "Nepodporovaná aktivita: {json}",
+ "activity.source": "Z tvých poznámek",
+ "activity.speak": "Přečíst nahlas",
+ "activity.stopSpeaking": "Zastavit čtení",
/* flashcard ------------------------------------------------------- */
"flashcard.tapToFlip": "Klepnutím na kartu ji otočíš.",
diff --git a/lib/notes-source.ts b/lib/notes-source.ts
new file mode 100644
index 0000000..769fccf
--- /dev/null
+++ b/lib/notes-source.ts
@@ -0,0 +1,309 @@
+/**
+ * "Where in my notes did this come from?" — the study feed shows the passage
+ * that carries an activity's answer, with the matching words highlighted.
+ *
+ * Two paths, both local (nothing leaves the browser):
+ * - activities generated after the source-quote change carry a verbatim
+ * `source` quote; it is located in the stored notes and the sentence around
+ * it is shown, with the quote itself highlighted;
+ * - older activities have no quote, so the passage is found by scoring the
+ * notes' sentences against the activity's own wording. Rarer words weigh
+ * more (an IDF sum), so "Rayleigh" beats "the", and a sentence has to match
+ * at least two distinct terms to count.
+ *
+ * Returns null when nothing convincing is found — a random sentence presented
+ * as "where this came from" would be worse than showing nothing.
+ */
+
+import type { Activity } from "./schemas";
+
+export type NotesMatch = {
+ /** The passage to show. */
+ text: string;
+ /** Highlight spans inside `text` (start, end). */
+ spans: Array<[number, number]>;
+ /** True when the AI supplied the quote (vs. a local sentence match). */
+ quote: boolean;
+};
+
+/** Longest excerpt shown under an activity. */
+const MAX_EXCERPT = 280;
+
+const STOPWORDS = new Set([
+ "the", "and", "that", "with", "from", "this", "which", "are", "was", "were", "have", "has", "had",
+ "for", "not", "but", "you", "your", "they", "their", "into", "than", "then", "what", "when",
+ "where", "why", "how", "does", "did", "can", "could", "should", "would", "its", "one", "two",
+ "about", "over", "under", "more", "most", "some", "any", "all", "each", "also", "such", "only",
+ "very", "just", "like", "make", "made", "use", "used", "using", "how", "who", "whose", "will",
+]);
+
+const isWordCharacter = (character: string | undefined) =>
+ Boolean(character && /[\p{L}\p{N}]/u.test(character));
+
+/** Lower-case the words of a text, for term comparison. */
+export function termsOf(text: string): string[] {
+ return [
+ ...new Set(
+ text
+ .toLowerCase()
+ .split(/[^\p{L}\p{N}]+/u)
+ .filter((word) => word.length >= 3 && !STOPWORDS.has(word)),
+ ),
+ ];
+}
+
+/**
+ * The activity's own wording, used when no quote is stored: the question side
+ * plus the answer side, so both "what was asked" and "what the answer mentions"
+ * can hit a sentence in the notes.
+ */
+export function activityQuery(activity: Activity): string {
+ switch (activity.type) {
+ case "flashcard":
+ return `${activity.front} ${activity.back}`;
+ case "multiple_choice":
+ return `${activity.question} ${activity.options[activity.correctIndex] ?? ""}`;
+ case "true_false":
+ return activity.statement;
+ case "fill_blank":
+ return `${activity.sentence.replace(/_{2,}/g, " ")} ${activity.answer}`;
+ case "matching":
+ return activity.pairs.map((pair) => `${pair.term} ${pair.definition}`).join(" ");
+ case "ordering":
+ return activity.items.join(" ");
+ case "short_answer":
+ return `${activity.question} ${activity.referenceAnswer} ${activity.keyPoints.join(" ")}`;
+ default:
+ return "";
+ }
+}
+
+/** `text` plus a per-character index map into the original. */
+function normalise(text: string): { text: string; map: number[] } {
+ let out = "";
+ const map: number[] = [];
+ let pendingSpace = false;
+ for (let index = 0; index < text.length; index += 1) {
+ const character = text[index];
+ if (/\s/.test(character)) {
+ pendingSpace = out.length > 0;
+ continue;
+ }
+ if (pendingSpace) {
+ out += " ";
+ map.push(index);
+ pendingSpace = false;
+ }
+ out += character.toLowerCase();
+ map.push(index);
+ }
+ return { text: out, map };
+}
+
+/** The quote (normalised) inside the notes, or the longest verbatim word run. */
+function locateQuote(notes: string, quote: string): [number, number] | null {
+ const haystack = normalise(notes);
+ const needle = normalise(quote).text.replace(/…/g, "").trim();
+ if (needle.length < 8) return null;
+
+ const span = (from: number, to: number): [number, number] => [
+ haystack.map[from],
+ haystack.map[to - 1] + 1,
+ ];
+
+ const exact = haystack.text.indexOf(needle);
+ if (exact >= 0) return span(exact, exact + needle.length);
+
+ // The model paraphrased or trimmed the quote: fall back to the longest run
+ // of consecutive words that does appear verbatim.
+ const words = needle.split(" ").filter(Boolean);
+ for (let size = Math.min(words.length, 16); size >= 3; size -= 1) {
+ for (let start = 0; start + size <= words.length; start += 1) {
+ const chunk = words.slice(start, start + size).join(" ");
+ const at = haystack.text.indexOf(chunk);
+ if (at >= 0) return span(at, at + chunk.length);
+ }
+ }
+ return null;
+}
+
+/** Expand a match to the sentence (or line) around it. */
+function sentenceAround(text: string, start: number, end: number): [number, number] {
+ let from = start;
+ while (from > 0) {
+ const previous = text[from - 1];
+ if (previous === "\n" || previous === "." || previous === "!" || previous === "?") break;
+ from -= 1;
+ }
+ let to = end;
+ while (to < text.length) {
+ const character = text[to];
+ if (character === "\n" || character === "." || character === "!" || character === "?") {
+ to += 1;
+ break;
+ }
+ to += 1;
+ }
+ while (from < to && /\s/.test(text[from])) from += 1;
+ while (to > from && /\s/.test(text[to - 1])) to -= 1;
+ return [from, to];
+}
+
+/** Trim a passage to `max` characters around the first highlight. */
+function clip(
+ text: string,
+ start: number,
+ end: number,
+ spans: Array<[number, number]>,
+ max: number,
+): { text: string; spans: Array<[number, number]> } | null {
+ const length = end - start;
+ if (length <= max) {
+ return {
+ text: text.slice(start, end),
+ spans: spans
+ .map(([from, to]) => [Math.max(start, from), Math.min(end, to)] as [number, number])
+ .filter(([from, to]) => to > from)
+ .map(([from, to]) => [from - start, to - start] as [number, number]),
+ };
+ }
+ const anchor = spans[0] ?? [start, start];
+ const from = Math.max(start, Math.min(anchor[0] - Math.floor(max / 3), end - max));
+ const to = Math.min(end, from + max);
+ const prefix = from > start ? "… " : "";
+ const suffix = to < end ? " …" : "";
+ const trimmed = `${prefix}${text.slice(from, to).trim()}${suffix}`;
+ const shift = from + prefix.length;
+ return {
+ text: trimmed,
+ spans: spans
+ .map(([spanFrom, spanTo]) => [Math.max(from, spanFrom) - shift, Math.min(to, spanTo) - shift] as [number, number])
+ .filter(([spanFrom, spanTo]) => spanTo > spanFrom && spanFrom >= 0 && spanTo <= trimmed.length),
+ };
+}
+
+/** Sentence split that keeps offsets; no lookbehind (older Safari). */
+function sentences(text: string): Array<{ start: number; end: number }> {
+ const out: Array<{ start: number; end: number }> = [];
+ let start = 0;
+ const push = (end: number) => {
+ let from = start;
+ while (from < end && /\s/.test(text[from])) from += 1;
+ let to = end;
+ while (to > from && /\s/.test(text[to - 1])) to -= 1;
+ if (to - from >= 8) out.push({ start: from, end: to });
+ start = end;
+ };
+ for (let index = 0; index < text.length; index += 1) {
+ const character = text[index];
+ if (character === "\n") {
+ push(index);
+ start = index + 1;
+ continue;
+ }
+ if ((character === "." || character === "!" || character === "?") && /\s/.test(text[index + 1] ?? "\n")) {
+ push(index + 1);
+ start = index + 1;
+ }
+ }
+ push(text.length);
+ return out;
+}
+
+/** Every occurrence of the matched terms in a sentence (word boundaries). */
+function termSpans(text: string, matched: Set): Array<[number, number]> {
+ const { text: haystack, map } = normalise(text);
+ const spans: Array<[number, number]> = [];
+ for (const term of matched) {
+ let at = haystack.indexOf(term);
+ while (at >= 0) {
+ if (!isWordCharacter(haystack[at - 1]) && !isWordCharacter(haystack[at + term.length])) {
+ spans.push([map[at], map[at + term.length - 1] + 1]);
+ }
+ at = haystack.indexOf(term, at + 1);
+ }
+ }
+ return spans.sort((a, b) => a[0] - b[0]);
+}
+
+/** The best-matching sentence for a query, IDF-weighted. */
+export function bestSentence(notes: string, query: string): NotesMatch | null {
+ const list = sentences(notes);
+ if (list.length === 0) return null;
+ const queryTerms = termsOf(query);
+ if (queryTerms.length === 0) return null;
+
+ const perSentence = list.map((sentence) => new Set(termsOf(notes.slice(sentence.start, sentence.end))));
+ const documentFrequency = new Map();
+ for (const unique of perSentence) {
+ for (const term of unique) documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
+ }
+ const total = list.length;
+
+ let best: { index: number; score: number; matched: Set } | null = null;
+ perSentence.forEach((unique, index) => {
+ const matched = new Set();
+ let score = 0;
+ for (const term of queryTerms) {
+ if (!unique.has(term)) continue;
+ matched.add(term);
+ score += Math.log(1 + total / (documentFrequency.get(term) ?? 1));
+ }
+ const enough = matched.size >= 2 || (matched.size === 1 && total <= 3);
+ if (!enough) return;
+ if (!best || score > best.score) best = { index, score, matched };
+ });
+ if (!best) return null;
+
+ const winner: { index: number; score: number; matched: Set } = best;
+ const sentence = list[winner.index];
+ const spans = termSpans(notes.slice(sentence.start, sentence.end), winner.matched);
+ const clipped = clip(notes, sentence.start, sentence.end, spans, MAX_EXCERPT);
+ if (!clipped) return null;
+ return { text: clipped.text, spans: clipped.spans, quote: false };
+}
+
+/**
+ * The passage that carries this activity's answer, from the AI's own quote
+ * when there is one and by local matching otherwise.
+ */
+export function notesSource(notes: string | undefined, activity: Activity): NotesMatch | null {
+ const text = (notes ?? "").trim();
+ if (text.length < 24) return null;
+
+ if (activity.source) {
+ const located = locateQuote(text, activity.source);
+ if (located) {
+ const [start, end] = sentenceAround(text, located[0], located[1]);
+ const clipped = clip(text, start, end, [located], MAX_EXCERPT);
+ if (clipped) return { text: clipped.text, spans: clipped.spans, quote: true };
+ }
+ }
+ return bestSentence(text, activityQuery(activity));
+}
+
+/** Splits a passage into pieces for rendering, marking the highlighted ones. */
+export function highlightSegments(
+ text: string,
+ spans: Array<[number, number]>,
+): Array<{ text: string; hit: boolean }> {
+ const merged = [...spans]
+ .filter(([from, to]) => to > from && from >= 0 && to <= text.length)
+ .sort((a, b) => a[0] - b[0])
+ .reduce>((list, span) => {
+ const last = list[list.length - 1];
+ if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]);
+ else list.push([span[0], span[1]]);
+ return list;
+ }, []);
+ if (merged.length === 0) return [{ text, hit: false }];
+ const out: Array<{ text: string; hit: boolean }> = [];
+ let at = 0;
+ for (const [from, to] of merged) {
+ if (from > at) out.push({ text: text.slice(at, from), hit: false });
+ out.push({ text: text.slice(from, to), hit: true });
+ at = to;
+ }
+ if (at < text.length) out.push({ text: text.slice(at), hit: false });
+ return out;
+}
diff --git a/lib/prompts.ts b/lib/prompts.ts
index 447dd5b..a4793ec 100644
--- a/lib/prompts.ts
+++ b/lib/prompts.ts
@@ -64,28 +64,28 @@ export const VISION_USER_INSTRUCTION = `Transcribe these notes and extract the s
/* Step 2 - activity generation */
/* ------------------------------------------------------------------ */
-export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type" field. Exactly these seven types exist:
+export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type" field. Exactly these seven types exist. Each one names the material it suits best - pick types that fit the notes, never force a type that does not.
-1. flashcard - tap to flip
+1. flashcard - tap to flip. Best for language vocabulary, terms to remember and quick recall.
{ "type": "flashcard", "topic": "short label", "front": "question or term", "back": "answer or definition", "explanation": "why this matters" }
-2. multiple_choice - exactly 4 options, exactly one correct
+2. multiple_choice - exactly 4 options, exactly one correct. Best for single facts that are easy to confuse, and for definitions that need a plausible-sounding wrong option.
{ "type": "multiple_choice", "topic": "short label", "question": "...", "options": ["A", "B", "C", "D"], "correctIndex": 0, "explanation": "why the correct option is right and the others are wrong" }
Put the correct answer at a varied position across the deck (not always 0!).
-3. true_false - one statement
+3. true_false - one statement. Best for fine distinctions, exceptions and common mix-ups.
{ "type": "true_false", "topic": "short label", "statement": "...", "answer": true, "explanation": "why it is true/false" }
Roughly half of them should be false, and false statements must be subtly wrong (a swapped term, a wrong number), never absurd.
-4. fill_blank - the sentence contains the placeholder "____"
+4. fill_blank - the sentence contains the placeholder "____". Best for key terms, formulas and language vocabulary that has to be produced inside a sentence (genders, prepositions, endings).
{ "type": "fill_blank", "topic": "short label", "sentence": "Mitochondria produce ____ for the cell.", "answer": "ATP", "options": ["ATP", "DNA", "glucose", "water"], "explanation": "..." }
"options" is optional but preferred: 3-6 plausible choices including the answer.
-5. matching - 3 to 6 term/definition pairs
+5. matching - 3 to 6 term/definition pairs. Best for vocabulary, terms to definitions, symbols to meanings, words to translations, dates to events - the strongest type for language decks.
Every term and definition must be distinct: a definition describes exactly one term, so never reuse a definition for two pairs.
{ "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." }
-6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them)
+6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them). Best for material with a real sequence.
Use it ONLY when the notes contain a real sequence the learner can reason about: historical or
chronological events, the stages of a process, steps of a procedure, a lifecycle, a ranked scale.
If the notes only list unrelated things (works, authors, terms, places, values), do NOT invent an
@@ -95,11 +95,11 @@ export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type
the learner has to work out the order from what each item says, not read it off.
{ "type": "ordering", "topic": "short label", "instruction": "Put the events in the order they happened.", "items": ["The Allies land in Normandy", "Paris is liberated", "Germany surrenders"], "explanation": "..." }
-7. short_answer - typed answer, graded by AI
+7. short_answer - typed answer, graded by AI. Best for asking why or how something works, or for applying a rule.
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea the question asks for", "second idea"], "explanation": "..." }
keyPoints must contain only the ideas needed to answer the question — never extras the question does not ask about (dates, places, names, numbers). For a "why" question require the reason, not the year.
-Every activity must also have "topic" (2-4 words) and "explanation" (one or two sentences, plain language).`;
+Every activity must also have "topic" (2-4 words), "explanation" (one or two sentences, plain language) and "source": the exact words from the notes that prove the answer — one phrase or one sentence, at most 25 words, copied verbatim (no ellipses, no edits, same language as the notes). If several places mention it, pick the clearest one. The app shows this quote back to the learner under "From your notes".`;
export const GENERATION_SYSTEM_PROMPT = `You are Freezmo's activity designer. You turn a student's own notes into a deck of short, playful, interactive study activities.
@@ -109,6 +109,7 @@ Hard rules:
- Use ONLY information contained in the provided notes. Never invent facts, definitions, numbers, dates or formulas. If the notes do not contain enough material for the requested number of activities, create fewer - quality over quantity.
- Every explanation must be answerable from the notes; do not introduce outside knowledge.
- Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row.
+- Match the mix to the material: language decks lean on flashcards, matching and fill_blank; historical or process material on ordering and true_false; reasoning and explanations on short_answer and multiple_choice.
- Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts.
- Make the wrong options plausible (common mix-ups from the same notes), never silly filler.
- Every option, item, term and definition on a card must be unique: never repeat the same wording
diff --git a/lib/schemas.ts b/lib/schemas.ts
index 8aedb1b..e3c863f 100644
--- a/lib/schemas.ts
+++ b/lib/schemas.ts
@@ -25,6 +25,12 @@ const baseActivity = {
/** Short label shown in the feed chrome, e.g. "Cell biology". */
topic: z.string().min(1).max(120).optional(),
explanation: z.string().min(1).max(600),
+ /**
+ * The words from the student's own notes that carry this answer. The feed
+ * shows them back after answering ("From your notes", with the matching
+ * passage highlighted), so the quote has to be verbatim.
+ */
+ source: z.string().min(1).max(1200).optional(),
};
export const flashcardSchema = z.object({
diff --git a/lib/speech.ts b/lib/speech.ts
new file mode 100644
index 0000000..822f0de
--- /dev/null
+++ b/lib/speech.ts
@@ -0,0 +1,150 @@
+"use client";
+
+/**
+ * Text-to-speech for study cards, on the browser's own Web Speech API.
+ *
+ * No dependency and no audio leaves the app ourselves: the voice, the engine
+ * and any cloud fallback belong to the operating system/browser. The module
+ * keeps one utterance at a time app-wide (two cards reading over each other is
+ * never what anyone wants) and notifies subscribers so the button that started
+ * it can flip to "stop".
+ */
+
+const listeners = new Set<() => void>();
+let speakingId: string | null = null;
+
+function notify(): void {
+ listeners.forEach((listener) => listener());
+}
+
+export function speechAvailable(): boolean {
+ return typeof window !== "undefined" && "speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined";
+}
+
+/** Language names ("Czech") and codes ("cs") → a BCP-47 tag for the voice. */
+const LOCALES: Record = {
+ english: "en-US",
+ czech: "cs-CZ",
+ slovak: "sk-SK",
+ polish: "pl-PL",
+ german: "de-DE",
+ french: "fr-FR",
+ spanish: "es-ES",
+ italian: "it-IT",
+ portuguese: "pt-PT",
+ dutch: "nl-NL",
+ swedish: "sv-SE",
+ norwegian: "nb-NO",
+ danish: "da-DK",
+ finnish: "fi-FI",
+ hungarian: "hu-HU",
+ romanian: "ro-RO",
+ turkish: "tr-TR",
+ croatian: "hr-HR",
+ slovenian: "sl-SI",
+ serbian: "sr-RS",
+ russian: "ru-RU",
+ ukrainian: "uk-UA",
+ bulgarian: "bg-BG",
+ belarusian: "be-BY",
+ macedonian: "mk-MK",
+ greek: "el-GR",
+ chinese: "zh-CN",
+ mandarin: "zh-CN",
+ japanese: "ja-JP",
+ korean: "ko-KR",
+ arabic: "ar-SA",
+ persian: "fa-IR",
+ farsi: "fa-IR",
+ urdu: "ur-PK",
+ hebrew: "he-IL",
+ hindi: "hi-IN",
+ marathi: "mr-IN",
+ nepali: "ne-NP",
+ thai: "th-TH",
+ vietnamese: "vi-VN",
+};
+
+/**
+ * A BCP-47 tag for the deck's language, falling back to the interface locale
+ * (so an untagged deck reads with the voice the user already hears the app in).
+ */
+export function speechLocale(language: string | undefined, fallback: string): string {
+ const key = (language ?? "").trim().toLowerCase().replace(/[_.]/g, "-").split("-")[0].replace(/[^a-z]/g, "");
+ if (key && LOCALES[key]) return LOCALES[key];
+ if (key.length === 2) return language!.trim();
+ return fallback === "cs" ? "cs-CZ" : "en-US";
+}
+
+/** The installed voice closest to `locale` (exact match, then language). */
+function pickVoice(locale: string): SpeechSynthesisVoice | null {
+ try {
+ const voices = window.speechSynthesis.getVoices();
+ if (voices.length === 0) return null;
+ const wanted = locale.toLowerCase();
+ const prefix = wanted.split("-")[0];
+ return (
+ voices.find((voice) => voice.lang.toLowerCase() === wanted) ??
+ voices.find((voice) => voice.lang.toLowerCase().replace("_", "-") === wanted) ??
+ voices.find((voice) => voice.lang.toLowerCase().startsWith(`${prefix}-`) || voice.lang.toLowerCase() === prefix) ??
+ null
+ );
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Reads `text` aloud under `id` (usually the button's own React id, so the
+ * button that started it is the one that shows "stop").
+ */
+export function speak(id: string, text: string, locale: string): void {
+ if (!speechAvailable()) return;
+ const clean = text.replace(/…/g, ", ").replace(/_{2,}/g, "… ").trim();
+ if (!clean) return;
+ try {
+ window.speechSynthesis.cancel();
+ const utterance = new SpeechSynthesisUtterance(clean);
+ utterance.lang = locale;
+ utterance.rate = 0.95;
+ const voice = pickVoice(locale);
+ if (voice) utterance.voice = voice;
+ const done = () => {
+ if (speakingId === id) {
+ speakingId = null;
+ notify();
+ }
+ };
+ utterance.onend = done;
+ utterance.onerror = done;
+ speakingId = id;
+ notify();
+ window.speechSynthesis.speak(utterance);
+ } catch {
+ speakingId = null;
+ notify();
+ }
+}
+
+export function stopSpeaking(): void {
+ if (!speechAvailable()) return;
+ try {
+ window.speechSynthesis.cancel();
+ } catch {
+ /* already gone */
+ }
+ if (speakingId !== null) {
+ speakingId = null;
+ notify();
+ }
+}
+
+/** True while `id`'s utterance is the one playing. */
+export function isSpeaking(id: string): boolean {
+ return speakingId === id;
+}
+
+export function subscribeSpeech(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
diff --git a/package.json b/package.json
index 14f03c0..d0be11b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "freezmo",
- "version": "0.6.0",
+ "version": "0.6.1",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
diff --git a/public/sw.js b/public/sw.js
index 545074a..10b5a55 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-v82";
+const CACHE_VERSION = "freezmo-v83";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
diff --git a/tests/activities-test.mjs b/tests/activities-test.mjs
new file mode 100644
index 0000000..91a2bb8
--- /dev/null
+++ b/tests/activities-test.mjs
@@ -0,0 +1,209 @@
+/**
+ * The study-card extras: "From your notes" (the AI's own quote, and the local
+ * sentence match for decks made before quotes existed), read-aloud cards
+ * (Web Speech API, with the engine stubbed so the suite stays deterministic)
+ * and timed runs (the restart-able countdown in the feed chrome).
+ */
+import { BASE, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
+
+const { check, finish } = makeChecker();
+const browser = await launchBrowser();
+
+const NOTES =
+ "Rayleigh scattering makes the sky blue: molecules scatter short wavelengths far more than long ones. Chlorophyll absorbs red and blue light, which is why plants look green.";
+
+/** Two cards: one with the generator's verbatim quote, one from before that. */
+const DECK = {
+ id: "seed_deck",
+ title: "Sky deck",
+ subject: "Physics",
+ summary: "Light in the atmosphere.",
+ language: "English",
+ sourceText: NOTES,
+ difficulty: "easy",
+ createdAt: 1,
+ updatedAt: 1,
+ attempts: 0,
+ activities: [
+ {
+ type: "multiple_choice",
+ id: "a1",
+ xp: 10,
+ topic: "Sky",
+ question: "What colour is a clear daytime sky?",
+ options: ["Blue", "Green", "Orange", "Pink"],
+ correctIndex: 0,
+ explanation: "Short wavelengths scatter more.",
+ source: "molecules scatter short wavelengths far more than long ones",
+ },
+ {
+ type: "flashcard",
+ id: "a2",
+ xp: 5,
+ topic: "Sky",
+ front: "What makes the sky blue?",
+ back: "Rayleigh scattering",
+ explanation: "Small molecules scatter short wavelengths.",
+ },
+ ],
+};
+
+/* 1. From your notes + read aloud --------------------------------------- */
+{
+ const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
+ await seed(page, { decks: [DECK], theme: "dark" });
+ // Deterministic speech: the engine is a recorder, the utterance API is real,
+ // and nothing ever fires "end" — so the button settles on its playing state.
+ await page.addInitScript(() => {
+ window.__spoken = [];
+ window.__cancelled = 0;
+ Object.defineProperty(window, "speechSynthesis", {
+ configurable: true,
+ value: {
+ speak: (utterance) => window.__spoken.push(String(utterance.text)),
+ cancel: () => {
+ window.__cancelled += 1;
+ },
+ getVoices: () => [],
+ speaking: false,
+ paused: false,
+ },
+ });
+ });
+ await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(1200);
+
+ const first = page.locator("section[aria-label]").nth(0);
+ const second = page.locator("section[aria-label]").nth(1);
+
+ /* The AI's own quote: located in the stored notes, quote highlighted. */
+ await first.getByRole("button", { name: "Blue" }).click();
+ await page.waitForTimeout(600);
+ const quoted = first.locator("[data-notes-source]");
+ check("source: the feedback shows where the answer came from", await quoted.isVisible());
+ check(
+ "source: the quote's sentence from the notes is shown",
+ ((await quoted.textContent()) ?? "").includes("Rayleigh scattering makes the sky blue"),
+ ((await quoted.textContent()) ?? "").slice(0, 80),
+ );
+ const quotedMark = quoted.locator("mark").first();
+ check(
+ "source: the quote itself is the highlighted part",
+ ((await quotedMark.textContent()) ?? "").includes("molecules scatter short wavelengths far more than long ones"),
+ String(await quotedMark.textContent()),
+ );
+ const markRgb = await quotedMark.evaluate((element) => {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = getComputedStyle(element).backgroundColor;
+ ctx.fillRect(0, 0, 1, 1);
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
+ return { r, g, b };
+ });
+ check("source: the highlight is green", markRgb.g > markRgb.r + 10 && markRgb.g > markRgb.b, JSON.stringify(markRgb));
+ check("source: the quote path is flagged", (await quoted.getAttribute("data-notes-quote")) === "1");
+
+ /* Read aloud, on the flashcard that follows. */
+ await first.getByRole("button", { name: "Next" }).click();
+ await page.waitForTimeout(900);
+ const speak = second.getByRole("button", { name: "Read aloud" }).first();
+ check("speak: the card carries a read-aloud control", (await speak.count()) === 1);
+ await speak.click();
+ await page.waitForTimeout(300);
+ check(
+ "speak: the visible side is handed to the speech engine",
+ ((await page.evaluate(() => window.__spoken)) ?? []).includes("What makes the sky blue?"),
+ JSON.stringify(await page.evaluate(() => window.__spoken)),
+ );
+ check("speak: the control flips to stop", (await second.getByRole("button", { name: "Stop reading" }).count()) === 1);
+ check(
+ "speak: tapping the speaker does not flip the card",
+ (await second.getByRole("button", { name: "Reveal the answer" }).count()) === 1,
+ );
+ await second.getByRole("button", { name: "Stop reading" }).click();
+ await page.waitForTimeout(300);
+ check("speak: stopping cancels the utterance", (await page.evaluate(() => window.__cancelled)) >= 1);
+ check("speak: the control flips back", (await second.getByRole("button", { name: "Read aloud" }).count()) === 1);
+
+ /* The local fallback: this card has no quote, so the sentence is matched. */
+ await second.getByRole("button", { name: "Reveal the answer" }).click();
+ await page.waitForTimeout(600);
+ await second.getByRole("button", { name: /I knew it/ }).click();
+ await page.waitForTimeout(800);
+ const local = second.locator("[data-notes-source]");
+ check("source: a deck without quotes still points at the notes", await local.isVisible());
+ check(
+ "source: the local match is the right sentence",
+ ((await local.textContent()) ?? "").includes("Rayleigh scattering makes the sky blue"),
+ ((await local.textContent()) ?? "").slice(0, 80),
+ );
+ check("source: the matched words are marked", (await local.locator("mark").count()) >= 1);
+ check("source: the local path is flagged", (await local.getAttribute("data-notes-quote")) === "0");
+ await context.close();
+}
+
+/* 2. timed runs --------------------------------------------------------- */
+{
+ const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
+ await seed(page, { decks: [DECK] });
+ await page.clock.install();
+ // The fake clock has no animation frames, so the browser's smooth scrolling
+ // would never arrive (the feed tracks its slide from the scroll position).
+ // The harness scrolls instantly; everything else is the real app.
+ await page.addInitScript(() => {
+ const original = Element.prototype.scrollTo;
+ Element.prototype.scrollTo = function (options, y) {
+ if (options && typeof options === "object") return original.call(this, { ...options, behavior: "auto" });
+ return original.call(this, options, y);
+ };
+ });
+ await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(1200);
+
+ const toggle = page.locator("[data-timed-toggle]");
+ check(
+ "timed: the chrome carries the toggle, off by default",
+ (await toggle.count()) === 1 && (await toggle.getAttribute("aria-pressed")) === "false",
+ );
+ check("timed: the label names the per-card budget", /30s/.test((await toggle.textContent()) ?? ""), String(await toggle.textContent()));
+
+ await toggle.click();
+ await page.waitForTimeout(300);
+ check("timed: toggled on", (await toggle.getAttribute("aria-pressed")) === "true");
+ await page.clock.runFor(5000);
+ await page.waitForTimeout(200);
+ const afterFive = Number.parseInt((await toggle.textContent()) ?? "", 10);
+ check("timed: the countdown ticks down", afterFive <= 26 && afterFive > 0, String(afterFive));
+
+ await page.clock.runFor(26_000);
+ await page.waitForTimeout(300);
+ /* the feed advances a beat after the countdown hits zero */
+ await page.clock.runFor(1000);
+ await page.waitForTimeout(300);
+ check("timed: running out tells the learner", (await page.getByText(/Time's up/).count()) >= 1);
+ /* The feed's position, not the smooth-scroll animation: under the fake
+ clock the browser's smooth scrolling has no frames of its own. */
+ const position = await page.evaluate(() => {
+ const chip = document.querySelector("header .study-chrome span.tabular-nums");
+ return chip ? chip.textContent?.trim() ?? "" : "";
+ });
+ check("timed: the feed moves on by itself", position.startsWith("2/"), position);
+ check(
+ "timed: the timeout is recorded as a miss",
+ (await page.locator("[data-testid=study-feed]").evaluate((element) => element.scrollTop)) > 100,
+ );
+ const fresh = Number.parseInt((await toggle.textContent()) ?? "", 10);
+ check("timed: the next card gets a fresh budget", fresh >= 25 && fresh <= 30, String(fresh));
+
+ /* The second card times out too: the results screen counts the miss. */
+ await page.clock.runFor(31_000);
+ await page.waitForTimeout(600);
+ check(
+ "timed: both timeouts reach the results slide as misses",
+ (await page.getByText(/You got 0 of 2 right/).count()) === 1 && (await page.getByText(/Missed items \(2\)/).count()) === 1,
+ );
+ await context.close();
+}
+
+await browser.close();
+finish();
diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs
index ca99b52..d549d5d 100644
--- a/tests/phase2-test.mjs
+++ b/tests/phase2-test.mjs
@@ -391,6 +391,12 @@ const browser = await launchBrowser();
// feed for ArrowDown. Both activities have the correct answer at index 0.
await page.keyboard.press("1");
await page.waitForTimeout(500);
+ /* The feedback shows, but the "from your notes" passage stays hidden in
+ exam mode with the other reveals (the deck's notes do contain the answer). */
+ check(
+ "exam: no notes source under the feedback",
+ (await page.getByText("Why:").count()) >= 1 && (await page.locator("[data-notes-source]").count()) === 0,
+ );
await page.keyboard.press("ArrowDown");
await page.waitForTimeout(800);
await page.keyboard.press("2");
diff --git a/tests/run-all.mjs b/tests/run-all.mjs
index 4cbaded..9b478c5 100644
--- a/tests/run-all.mjs
+++ b/tests/run-all.mjs
@@ -13,6 +13,7 @@ import path from "node:path";
const SUITES = [
"regression",
+ "activities-test",
"bubbles-test",
"mobile-test",
"wizard-test",