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} + +