freezmo/components/homework/HomeworkGuide.tsx
Martin Dvorak df34337b4a Accessibility pass, report control for homework exercises, clearer rail actions
- 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.
2026-10-12 21:15:56 +00:00

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>
);
}