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.
243 lines
8.2 KiB
TypeScript
243 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { motion } from "framer-motion";
|
|
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
|
|
|
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;
|
|
/** True when this slide is the one currently filling the screen. */
|
|
active: boolean;
|
|
/** Language of the deck, so AI grading answers in the same language. */
|
|
language?: string;
|
|
/** Report the result to the feed (once per attempt). */
|
|
onAnswered: (correct: boolean, meta?: { userAnswer?: string }) => void;
|
|
/** Scroll the feed to the next slide. */
|
|
onNext: () => void;
|
|
isLast: boolean;
|
|
/** Overrides the feedback button label (the homework guide uses this). */
|
|
nextLabel?: string;
|
|
/** Exam mode: hide hint rows and answer-reveal affordances. */
|
|
noHints?: boolean;
|
|
};
|
|
|
|
export const TYPE_META: Record<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
|
|
flashcard: { labelKey: "type.flashcard", icon: "🃏", tint: "tint-sky" },
|
|
multiple_choice: { labelKey: "type.multipleChoice", icon: "🔘", tint: "tint-lilac" },
|
|
true_false: { labelKey: "type.trueFalse", icon: "⚖️", tint: "tint-mint" },
|
|
fill_blank: { labelKey: "type.fillBlank", icon: "✏️", tint: "tint-peach" },
|
|
matching: { labelKey: "type.matching", icon: "🧲", tint: "tint-sky" },
|
|
ordering: { labelKey: "type.ordering", icon: "🔢", tint: "tint-lilac" },
|
|
short_answer: { labelKey: "type.shortAnswer", icon: "💬", tint: "tint-rose" },
|
|
};
|
|
export function ActivityHeader({
|
|
type,
|
|
topic,
|
|
extra,
|
|
}: {
|
|
type: Activity["type"];
|
|
topic?: string;
|
|
extra?: React.ReactNode;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const meta = TYPE_META[type];
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm",
|
|
meta.tint,
|
|
)}
|
|
>
|
|
<span aria-hidden>{meta.icon}</span>
|
|
{t(meta.labelKey)}
|
|
</span>
|
|
{topic ? (
|
|
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
|
|
{topic}
|
|
</span>
|
|
) : null}
|
|
{extra}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PromptCard({
|
|
children,
|
|
className,
|
|
style,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn("card glass-solid rounded-[1.5rem] px-5 py-6 text-balance sm:px-6", className)}
|
|
style={style}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function QuestionText({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
return (
|
|
<p className={cn("text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50", className)}>
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/** Standard tile used for answer options. */
|
|
export function OptionTile({
|
|
children,
|
|
state = "idle",
|
|
disabled,
|
|
onClick,
|
|
className,
|
|
shortcut,
|
|
}: {
|
|
children: React.ReactNode;
|
|
state?: "idle" | "selected" | "correct" | "wrong" | "dim";
|
|
disabled?: boolean;
|
|
onClick?: () => void;
|
|
className?: string;
|
|
shortcut?: string;
|
|
}) {
|
|
const states: Record<string, string> = {
|
|
idle: "border-white/60 bg-white/55 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.07] dark:hover:bg-white/[0.12]",
|
|
selected: "border-accent-400 bg-accent-50/80 dark:bg-accent-500/20",
|
|
correct:
|
|
"border-emerald-300 bg-emerald-50/90 text-emerald-950 dark:border-emerald-400/50 dark:bg-emerald-400/15 dark:text-emerald-50",
|
|
wrong: "border-rose-300 bg-rose-50/90 text-rose-950 dark:border-rose-400/50 dark:bg-rose-400/15 dark:text-rose-50",
|
|
dim: "border-white/40 bg-white/35 text-frost-400 dark:border-white/10 dark:bg-white/[0.04] dark:text-frost-500",
|
|
};
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={cn(
|
|
"flex w-full items-center gap-3 rounded-2xl border px-4 py-3.5 text-left text-[15px] font-medium backdrop-blur-xl transition-all duration-200 disabled:cursor-default",
|
|
states[state],
|
|
!disabled && "active:scale-[0.99]",
|
|
className,
|
|
)}
|
|
>
|
|
{shortcut ? (
|
|
<span className="grid size-6 shrink-0 place-items-center rounded-lg bg-[var(--surface-sunken)] text-xs font-bold text-frost-600 dark:text-frost-300">
|
|
{shortcut}
|
|
</span>
|
|
) : null}
|
|
<span className="flex-1">{children}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function FeedbackPanel({
|
|
correct,
|
|
explanation,
|
|
xp,
|
|
onNext,
|
|
isLast,
|
|
nextLabel,
|
|
headline,
|
|
detail,
|
|
activity,
|
|
children,
|
|
}: {
|
|
correct: boolean;
|
|
explanation: string;
|
|
xp?: number;
|
|
onNext: () => void;
|
|
isLast?: boolean;
|
|
/** Overrides the default “Next” / “See results” label. */
|
|
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();
|
|
const isPhone = useIsPhone();
|
|
const reduceMotion = useReducedMotion();
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 18 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
|
className={cn(
|
|
"rounded-3xl border p-4 shadow-lift backdrop-blur-xl",
|
|
correct
|
|
? "border-emerald-200/80 bg-gradient-to-b from-emerald-50/95 to-emerald-100/70 dark:border-emerald-400/30 dark:from-emerald-500/15 dark:to-emerald-500/5"
|
|
: "border-rose-200/80 bg-gradient-to-b from-rose-50/95 to-rose-100/70 dark:border-rose-400/30 dark:from-rose-500/15 dark:to-rose-500/5",
|
|
)}
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p
|
|
className={cn(
|
|
"flex items-center gap-2 text-base font-extrabold",
|
|
correct ? "text-emerald-900 dark:text-emerald-200" : "text-rose-900 dark:text-rose-200",
|
|
/* Phones: a soft pulse on correct, a quick shake on wrong. */
|
|
isPhone && !reduceMotion && (correct ? "animate-pop" : "animate-wiggle"),
|
|
)}
|
|
>
|
|
<span aria-hidden>{correct ? "✅" : "💧"}</span>
|
|
{headline ?? (correct ? t("activity.niceOne") : t("activity.notQuite"))}
|
|
</p>
|
|
{correct && xp ? (
|
|
<motion.span
|
|
initial={{ scale: 0.6, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
className="rounded-full bg-emerald-500 px-3 py-1 text-sm font-extrabold text-white shadow"
|
|
>
|
|
+{xp} XP
|
|
</motion.span>
|
|
) : null}
|
|
</div>
|
|
|
|
{detail ? <div className="mt-2 text-sm text-frost-700 dark:text-frost-200">{detail}</div> : null}
|
|
|
|
<p className="mt-2 text-sm leading-relaxed text-frost-700 dark:text-frost-200">
|
|
<span className="font-bold">{t("activity.why")} </span>
|
|
{explanation}
|
|
</p>
|
|
|
|
{activity ? <NotesSource activity={activity} /> : null}
|
|
|
|
{children}
|
|
|
|
<div className="mt-4 flex items-center justify-end">
|
|
<Button onClick={onNext} size="md" className="min-w-28">
|
|
{nextLabel ?? (isLast ? t("activity.seeResults") : t("activity.next"))}
|
|
<span aria-hidden>→</span>
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/** Small hint row under a question, e.g. "Tap a term, then its definition". */
|
|
export function Hint({ children }: { children: React.ReactNode }) {
|
|
const noHints = useNoHints();
|
|
if (noHints) return null;
|
|
// hint-line: Aero gives this its own glass chip (it sits on the wallpaper).
|
|
return <p className="hint-line text-sm text-frost-600 dark:text-frost-300">{children}</p>;
|
|
}
|
|
|
|
/* The no-hints / notes-text contexts live in ./context: NotesSource reads them
|
|
while this file renders NotesSource, so keeping them apart avoids a cycle. */
|