- axe is clean across every screen in both themes, phone and desktop. Fixed: the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by labelled buttons, so they should not be in the accessibility tree), the heading order (deck/homework/smart-review cards are h2 under the page h1; LearningSection takes a level so it stays h2 on the library and h3 inside the settings dialog) and landmarks around the study and review feeds. - tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe cannot judge text on blurred glass — it reports those nodes as incomplete. It skips disabled controls (1.4.3 exempts them) and content behind an open sheet. Result: no below-minimum text on any scanned screen. - The report control exists in the homework guide too: under each "Try it yourself" exercise, labelled "Report exercise", without a skip (there is no queue to skip). lib/question-reports now takes deckId/deckTitle directly. - The rail's two creation actions are buttons now (.nav-action) rather than quiet nav rows — once the library is full they were the only way to start something on desktop and read as just more navigation. "Make my homework" is "New homework" wherever it is an action (rail, empty state, share chooser, manifest shortcut); the page title and mode switch keep their wording. Version 0.1.6 (three task bumps), SW -> freezmo-v52.
337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { motion } from "framer-motion";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { ActivityRenderer } from "@/components/activities";
|
|
import { PromptCard, QuestionText } from "@/components/activities/shared";
|
|
import { AiDisclaimer } from "@/components/AiDisclaimer";
|
|
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
|
|
import { XIcon } from "@/components/icons";
|
|
import { Button, ProgressBar } from "@/components/ui";
|
|
import { AskButton } from "@/components/chat/AskButton";
|
|
import { assembleSolution } from "@/lib/homework";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas";
|
|
import { useWakeLock } from "@/lib/use-wake-lock";
|
|
import { refineHomework } from "@/lib/client-api";
|
|
|
|
import { CopyButton } from "./CopyButton";
|
|
|
|
/**
|
|
* Mode 2: one step per screen in the same snap-feed as the study feed.
|
|
* Each step explains what to do, reveals hints one at a time (nudge → hint →
|
|
* the full answer), and can carry a checkpoint rendered by the shared activity
|
|
* components. The last screen assembles the solution from the step answers.
|
|
*/
|
|
export function HomeworkGuide({
|
|
record,
|
|
onClose,
|
|
onAgain,
|
|
onNew,
|
|
onToDeck,
|
|
againLoading,
|
|
}: {
|
|
record: HomeworkGuideRecord;
|
|
onClose: () => void;
|
|
onAgain: () => void;
|
|
onNew: () => void;
|
|
onToDeck: () => void;
|
|
againLoading: boolean;
|
|
}) {
|
|
const { t } = useI18n();
|
|
/* Keep the screen awake while working through the guide. */
|
|
useWakeLock(true);
|
|
const steps = record.steps;
|
|
const slideCount = steps.length + 1; // + the solution slide
|
|
const [index, setIndex] = useState(0);
|
|
const [revealed, setRevealed] = useState<Record<number, number>>({});
|
|
/** "Explain this step differently" overrides, per step index. */
|
|
const [explained, setExplained] = useState<Record<number, string>>({});
|
|
const [explaining, setExplaining] = useState<number | null>(null);
|
|
const [explainFailed, setExplainFailed] = useState<number | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const explainStep = async (stepIndex: number) => {
|
|
if (explaining !== null) return;
|
|
setExplaining(stepIndex);
|
|
setExplainFailed(null);
|
|
try {
|
|
const next = await refineHomework({
|
|
instruction: "explain",
|
|
text: steps[stepIndex].explanation,
|
|
context: record.sourceText,
|
|
});
|
|
setExplained((current) => ({ ...current, [stepIndex]: next }));
|
|
} catch {
|
|
setExplainFailed(stepIndex);
|
|
} finally {
|
|
setExplaining(null);
|
|
}
|
|
};
|
|
|
|
const goTo = useCallback(
|
|
(target: number) => {
|
|
const container = scrollRef.current;
|
|
if (!container) return;
|
|
const clamped = Math.max(0, Math.min(target, slideCount - 1));
|
|
container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" });
|
|
},
|
|
[slideCount],
|
|
);
|
|
|
|
// Track the visible slide (wheel, touch and programmatic scroll).
|
|
useEffect(() => {
|
|
const container = scrollRef.current;
|
|
if (!container) return;
|
|
let frame = 0;
|
|
const onScroll = () => {
|
|
cancelAnimationFrame(frame);
|
|
frame = requestAnimationFrame(() => {
|
|
const height = container.clientHeight || 1;
|
|
const current = Math.round(container.scrollTop / height);
|
|
setIndex((previous) => (previous === current ? previous : current));
|
|
});
|
|
};
|
|
container.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => {
|
|
container.removeEventListener("scroll", onScroll);
|
|
cancelAnimationFrame(frame);
|
|
};
|
|
}, []);
|
|
|
|
// Keyboard navigation, like the study feed.
|
|
useEffect(() => {
|
|
const onKey = (event: KeyboardEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return;
|
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") {
|
|
event.preventDefault();
|
|
goTo(index + 1);
|
|
} else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") {
|
|
event.preventDefault();
|
|
goTo(index - 1);
|
|
} else if (event.key === "Escape") {
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [goTo, index, onClose]);
|
|
|
|
const solution = useMemo(() => assembleSolution(record), [record]);
|
|
const progressValue = Math.min(index + 1, slideCount);
|
|
const onSolution = index >= steps.length;
|
|
|
|
const revealedCount = (stepIndex: number) => revealed[stepIndex] ?? 0;
|
|
|
|
const revealNext = (stepIndex: number, hints: string[]) =>
|
|
setRevealed((current) => ({
|
|
...current,
|
|
[stepIndex]: Math.min((current[stepIndex] ?? 0) + 1, hints.length),
|
|
}));
|
|
|
|
const hintButtonLabel = (step: HomeworkStep, stepIndex: number) => {
|
|
const count = revealedCount(stepIndex);
|
|
if (count === 0) return t("homework.guide.hint");
|
|
if (count === step.hints.length - 1) return t("homework.guide.hintAnswer");
|
|
return t("homework.guide.hintMore");
|
|
};
|
|
|
|
return (
|
|
<div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col">
|
|
{/* chrome ------------------------------------------------------- */}
|
|
{/* Same pill as the study feed: round close button, title + step above
|
|
the bar (nothing crowds the rounded ends). */}
|
|
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
|
<div className="study-chrome mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t("homework.guide.close")}
|
|
title={t("homework.guide.close")}
|
|
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
|
>
|
|
<XIcon className="size-4" />
|
|
</button>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="min-w-0 truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
|
|
{record.title}
|
|
</span>
|
|
<span className="shrink-0 text-[11.5px] font-bold tabular-nums text-[var(--text-muted)]">
|
|
{onSolution ? t("homework.guide.solution") : t("homework.guide.stepOf", { n: index + 1, total: steps.length })}
|
|
</span>
|
|
</div>
|
|
<ProgressBar
|
|
className="mt-1.5"
|
|
value={progressValue}
|
|
max={slideCount}
|
|
label={t("homework.guide.stepOf", { n: progressValue, total: slideCount })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* slides ------------------------------------------------------- */}
|
|
<div
|
|
ref={scrollRef}
|
|
data-testid="homework-guide"
|
|
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
|
|
>
|
|
{steps.map((step, stepIndex) => (
|
|
<section
|
|
key={stepIndex}
|
|
aria-label={t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
|
|
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
|
>
|
|
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="tint-sky 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">
|
|
{t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
|
|
</span>
|
|
</div>
|
|
|
|
<PromptCard>
|
|
<QuestionText>{step.title}</QuestionText>
|
|
{/* The model writes plain text with short lines and numbered
|
|
lists (see HOMEWORK_BASE_RULES) — keep its line breaks so
|
|
the step reads as steps, not one run-on paragraph. */}
|
|
<p className="mt-2 whitespace-pre-wrap break-words text-[15px] leading-relaxed text-frost-700 dark:text-frost-200">
|
|
{explained[stepIndex] ?? step.explanation}
|
|
</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="touch-target"
|
|
loading={explaining === stepIndex}
|
|
disabled={explaining !== null}
|
|
onClick={() => void explainStep(stepIndex)}
|
|
>
|
|
{t("homework.followup.step", { n: stepIndex + 1 })}
|
|
</Button>
|
|
{explainFailed === stepIndex ? (
|
|
<span className="text-[12px] font-medium text-rose-600 dark:text-rose-300">
|
|
{t("homework.followup.failed")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</PromptCard>
|
|
|
|
{/* progressive hints */}
|
|
<div className="space-y-2">
|
|
{step.hints.slice(0, revealedCount(stepIndex)).map((hint, hintIndex) => (
|
|
<motion.div
|
|
key={hintIndex}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="glass-chip rounded-2xl p-3 text-sm leading-relaxed text-[var(--text)]"
|
|
>
|
|
<span className="font-bold">{t("homework.guide.hintN", { n: hintIndex + 1 })}: </span>
|
|
<span className="whitespace-pre-wrap break-words">{hint}</span>
|
|
</motion.div>
|
|
))}
|
|
{revealedCount(stepIndex) < step.hints.length ? (
|
|
<Button
|
|
variant="secondary"
|
|
className="touch-target"
|
|
onClick={() => revealNext(stepIndex, step.hints)}
|
|
>
|
|
{hintButtonLabel(step, stepIndex)}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* checkpoint or a plain next button */}
|
|
{step.checkpoint ? (
|
|
<div className="space-y-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
|
{t("homework.guide.tryIt")}
|
|
</p>
|
|
<ActivityRenderer
|
|
activity={step.checkpoint}
|
|
active={index === stepIndex}
|
|
language={record.language}
|
|
isLast={stepIndex === steps.length - 1}
|
|
nextLabel={
|
|
stepIndex === steps.length - 1 ? t("homework.guide.showSolution") : t("homework.guide.next")
|
|
}
|
|
onAnswered={() => {
|
|
/* checkpoints are for the student, not for scoring */
|
|
}}
|
|
onNext={() => goTo(stepIndex + 1)}
|
|
/>
|
|
{/* Same report control as the study feeds, under the
|
|
exercise (the homework record is the "deck" here). */}
|
|
<div className="flex justify-end">
|
|
<ReportQuestionButton
|
|
deckId={record.id}
|
|
deckTitle={record.title}
|
|
activity={step.checkpoint}
|
|
labelKey="homework.report"
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Button className="touch-target self-start" onClick={() => goTo(stepIndex + 1)}>
|
|
{stepIndex === steps.length - 1 ? t("homework.guide.showSolution") : t("homework.guide.next")}
|
|
<span aria-hidden>→</span>
|
|
</Button>
|
|
)}
|
|
<AiDisclaimer />
|
|
</div>
|
|
</section>
|
|
))}
|
|
|
|
{/* the assembled solution -------------------------------------- */}
|
|
<section
|
|
aria-label={t("homework.guide.solution")}
|
|
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
|
|
>
|
|
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="tint-mint 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">
|
|
{t("homework.guide.solution")}
|
|
</span>
|
|
</div>
|
|
<PromptCard>
|
|
<p className="whitespace-pre-wrap break-words text-[14.5px] leading-relaxed text-[var(--text)]">
|
|
{solution}
|
|
</p>
|
|
</PromptCard>
|
|
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.guide.solutionNote")}</p>
|
|
<AiDisclaimer />
|
|
<div className="flex flex-wrap items-start gap-2">
|
|
<CopyButton text={solution} />
|
|
<AskButton
|
|
seed={{
|
|
label: record.title,
|
|
text: [
|
|
`${t("chat.seedAssignment")}:`,
|
|
record.sourceText,
|
|
"",
|
|
`${t("chat.seedSteps")}:`,
|
|
...steps.map((step, index) => `${index + 1}. ${step.title}${step.explanation ? ` — ${step.explanation}` : ""}`),
|
|
].join("\n"),
|
|
}}
|
|
/>
|
|
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
|
{t("homework.followup.redo")}
|
|
</Button>
|
|
<Button variant="secondary" className="touch-target" onClick={onToDeck}>
|
|
{t("homework.toDeck")}
|
|
</Button>
|
|
<Button variant="ghost" className="touch-target" onClick={onNew}>
|
|
{t("homework.result.new")}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|