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 ( + + ); +} 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 }) {
+
- + + ); } 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")} + +
) : ( - {activity.question} +
+ {activity.question} + +