Study cards: from-your-notes, read aloud, timed runs
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.
This commit is contained in:
parent
786d6cf1ae
commit
796e2e9fe9
26 changed files with 1068 additions and 51 deletions
51
components/SpeakButton.tsx
Normal file
51
components/SpeakButton.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (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 ? <StopIcon className="size-4" /> : <SpeakerIcon className="size-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(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<number | null>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
|
||||
// The deck's own notes travel with the feed: after an answer the feedback
|
||||
// panel points back into them (NotesSource).
|
||||
<NotesTextProvider value={currentDeck.sourceText}>
|
||||
<div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
|
||||
{/* 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 }) {
|
|||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-timed-toggle
|
||||
data-timed-seconds={TIMED_CARD_SECONDS}
|
||||
aria-pressed={timed}
|
||||
onClick={() => 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)]",
|
||||
)}
|
||||
>
|
||||
<ClockIcon className="size-3.5" />
|
||||
{timed && remaining !== null ? `${remaining}s` : `${TIMED_CARD_SECONDS}s`}
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
|
||||
|
|
@ -410,6 +466,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NotesTextProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FillBlankActivity>) {
|
||||
export function FillBlank({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FillBlankActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState("");
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
|
|
@ -141,6 +142,9 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLa
|
|||
)}
|
||||
{after}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<SpeakButton text={activity.sentence} language={language} />
|
||||
</div>
|
||||
</PromptCard>
|
||||
|
||||
{activity.options ? (
|
||||
|
|
@ -181,6 +185,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLa
|
|||
|
||||
{result !== null ? (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useState } from "react";
|
|||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -19,7 +20,7 @@ import {
|
|||
|
||||
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
||||
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FlashcardActivity>) {
|
||||
export function Flashcard({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FlashcardActivity>) {
|
||||
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 (
|
||||
<>
|
||||
<ActivityHeader type="flashcard" topic={activity.topic} />
|
||||
<Hint>{flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}</Hint>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Hint>{flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}</Hint>
|
||||
<SpeakButton text={flipped ? activity.back : activity.front} language={language} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -100,6 +104,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: A
|
|||
|
||||
{result !== null ? (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,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 {
|
||||
ActivityHeader,
|
||||
|
|
@ -16,7 +17,7 @@ import {
|
|||
|
||||
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
||||
|
||||
export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<MatchingActivity>) {
|
||||
export function Matching({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<MatchingActivity>) {
|
||||
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
|
|||
}
|
||||
/>
|
||||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<SpeakButton text={activity.instruction} language={language} />
|
||||
</div>
|
||||
<Hint>
|
||||
{done
|
||||
? t("matching.allMatched")
|
||||
|
|
@ -133,6 +137,7 @@ export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
|
|||
|
||||
{complete ? (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={misses === 0}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -20,6 +21,7 @@ type MultipleChoiceActivity = Extract<Activity, { type: "multiple_choice" }>;
|
|||
export function MultipleChoice({
|
||||
activity,
|
||||
active,
|
||||
language,
|
||||
onAnswered,
|
||||
onNext,
|
||||
isLast,
|
||||
|
|
@ -62,7 +64,10 @@ export function MultipleChoice({
|
|||
<>
|
||||
<ActivityHeader type="multiple_choice" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
<SpeakButton text={activity.question} language={language} />
|
||||
</div>
|
||||
</PromptCard>
|
||||
|
||||
<div className="flex flex-col gap-2.5" role="group" aria-label={t("activity.answerOptions")}>
|
||||
|
|
@ -83,6 +88,7 @@ export function MultipleChoice({
|
|||
|
||||
{answered ? (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={selected === activity.correctIndex}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
55
components/activities/NotesSource.tsx
Normal file
55
components/activities/NotesSource.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { NotesIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { highlightSegments, notesSource } from "@/lib/notes-source";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
|
||||
import { useNoHints, useNotesText } from "./context";
|
||||
|
||||
/**
|
||||
* "From your notes": the passage an activity's answer comes from, with the
|
||||
* matching words highlighted green.
|
||||
*
|
||||
* The quote is the one the generator stored with the activity (verbatim from
|
||||
* the notes); decks made before that, or quotes the model trimmed, fall back
|
||||
* to a local, IDF-weighted sentence match over the same notes text. It sits
|
||||
* inside the feedback panel, so it only appears once the learner has answered,
|
||||
* and it disappears in exam mode with the other reveals.
|
||||
*/
|
||||
export function NotesSource({ activity }: { activity: Activity }) {
|
||||
const { t } = useI18n();
|
||||
const notes = useNotesText();
|
||||
const noHints = useNoHints();
|
||||
const match = useMemo(() => (noHints ? null : notesSource(notes, activity)), [noHints, notes, activity]);
|
||||
if (!match) return null;
|
||||
const pieces = highlightSegments(match.text, match.spans);
|
||||
return (
|
||||
<div className="mt-3 rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] p-3">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-wider text-[var(--text-muted)]">
|
||||
<NotesIcon className="size-3.5" />
|
||||
{t("activity.source")}
|
||||
</p>
|
||||
<p
|
||||
className="text-[13px] leading-relaxed text-[var(--text)]"
|
||||
data-notes-source
|
||||
data-notes-quote={match.quote ? "1" : "0"}
|
||||
>
|
||||
{pieces.map((piece, index) =>
|
||||
piece.hit ? (
|
||||
<mark
|
||||
key={index}
|
||||
className="rounded bg-emerald-200/85 px-0.5 text-emerald-950 dark:bg-emerald-400/35 dark:text-emerald-50"
|
||||
>
|
||||
{piece.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{piece.text}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Activity, { type: "ordering" }>;
|
||||
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<OrderingActivity>) {
|
||||
export function Ordering({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<OrderingActivity>) {
|
||||
const { t } = useI18n();
|
||||
const initial = useMemo(() => {
|
||||
const out = shuffle(activity.items);
|
||||
|
|
@ -54,7 +55,10 @@ export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
|
|||
<>
|
||||
<ActivityHeader type="ordering" topic={activity.topic} />
|
||||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<SpeakButton text={activity.instruction} language={language} />
|
||||
</div>
|
||||
<Hint>
|
||||
{checked ? t("ordering.correctOrder") : t("ordering.hint")}
|
||||
</Hint>
|
||||
|
|
@ -128,6 +132,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: Ac
|
|||
</Button>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={correct}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { Activity } from "@/lib/schemas";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -82,7 +83,10 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
<>
|
||||
<ActivityHeader type="short_answer" topic={activity.topic} />
|
||||
<PromptCard>
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<QuestionText>{activity.question}</QuestionText>
|
||||
<SpeakButton text={activity.question} language={language} />
|
||||
</div>
|
||||
</PromptCard>
|
||||
|
||||
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
|
||||
|
|
@ -186,6 +190,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
</div>
|
||||
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={state.correct}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useState } from "react";
|
|||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
import { SpeakButton } from "@/components/SpeakButton";
|
||||
|
||||
import {
|
||||
ActivityHeader,
|
||||
|
|
@ -17,7 +18,7 @@ import {
|
|||
|
||||
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
export function TrueFalse({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const x = useMotionValue(0);
|
||||
|
|
@ -65,6 +66,9 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: A
|
|||
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
|
||||
“{activity.statement}”
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<SpeakButton text={activity.statement} language={language} />
|
||||
</div>
|
||||
</PromptCard>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
@ -80,6 +84,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: A
|
|||
</div>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
activity={activity}
|
||||
correct={result}
|
||||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
|
|
|
|||
36
components/activities/context.tsx
Normal file
36
components/activities/context.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/**
|
||||
* The two ambient facts an activity card needs from the screen around it:
|
||||
*
|
||||
* - the deck's own notes text, so the feedback panel can show the passage an
|
||||
* answer came from (NotesSource). Absent on screens with no notes (the
|
||||
* homework guide's checkpoints);
|
||||
* - whether reveals are off (exam mode), which hides the hints *and* that
|
||||
* passage — both give the answer away.
|
||||
*
|
||||
* Its own module (not shared.tsx) because NotesSource needs these hooks while
|
||||
* shared.tsx renders NotesSource; a cycle there would be fragile.
|
||||
*/
|
||||
|
||||
const NotesTextContext = createContext<string | undefined>(undefined);
|
||||
|
||||
export function NotesTextProvider({ value, children }: { value?: string; children: React.ReactNode }) {
|
||||
return <NotesTextContext.Provider value={value}>{children}</NotesTextContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNotesText(): string | undefined {
|
||||
return useContext(NotesTextContext);
|
||||
}
|
||||
|
||||
const NoHintsContext = createContext(false);
|
||||
|
||||
export function NoHintsProvider({ value, children }: { value: boolean; children: React.ReactNode }) {
|
||||
return <NoHintsContext.Provider value={value}>{children}</NoHintsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNoHints(): boolean {
|
||||
return useContext(NoHintsContext);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<A extends Activity = Activity> = {
|
||||
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}
|
||||
</p>
|
||||
|
||||
{activity ? <NotesSource activity={activity} /> : null}
|
||||
|
||||
{children}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
|
|
@ -230,17 +239,5 @@ export function Hint({ children }: { children: React.ReactNode }) {
|
|||
return <p className="hint-line text-sm text-frost-600 dark:text-frost-300">{children}</p>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <NoHintsContext.Provider value={value}>{children}</NoHintsContext.Provider>;
|
||||
}
|
||||
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -399,3 +399,33 @@ export function ChromeIcon({ className }: IconProps) {
|
|||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-aloud control (study cards); the waves light up while speaking. */
|
||||
export function SpeakerIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4.5 9.4h2.9l4-3.2v11.6l-4-3.2H4.5z" />
|
||||
<path d="M15.2 9.2a3.9 3.9 0 0 1 0 5.6M17.7 6.7a7.3 7.3 0 0 1 0 10.6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Timed runs. */
|
||||
export function ClockIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="8.2" />
|
||||
<path d="M12 7.4V12l3.1 1.9" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** "From your notes" — a page with a folded corner and text lines. */
|
||||
export function NotesIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M6.4 3.6h7.4l4.8 4.8v12H6.4z" />
|
||||
<path d="M13.6 3.9v4.7h4.7M9.4 12.4h5.2M9.4 15.8h5.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue