Phase 3: Check my work, follow-ups and turn-into-deck

Check my work (third homework mode)
- One model call with its own system prompt marks the student's own
  attempt: verdict, what's right, each issue with why + fix, and the
  corrected version that keeps everything the student got right.
  Unasked-for extras are never errors. The attempt is typed/pasted
  (photos of handwriting are documented as unsupported) and stored in
  the record so Redo re-checks the same work.
- Server: mode "check" + solution on POST /api/homework (zod, 400
  server.needSolution without one) and the same NDJSON stream; new
  checkHomework() pipeline with repair-once validation.

Follow-ups
- New POST /api/homework/refine (plain JSON, RATE_LIMIT_REFINE, zod,
  auth before body): shorter / more detail / explain, plain text back,
  language preserved.
- Shorter + More detail + Original rewrite the finished answer and the
  corrected version in place (FollowUpBar); every guide step has
  Explain step N differently; failed calls say so without losing text.

Turn into a study deck
- Any result hands its content (answer, corrected version or assembled
  guide) to New deck through a one-shot sessionStorage seed with the
  options step pre-filled.

UI
- Phone step 2 gains the third card: tapping it reveals the solution
  field + submit; desktop has three cards and the solution field shows
  with the mode. Homework history labels and share/card previews cover
  the new mode.

Verified: new phase-3 suite 20/20 against a stub Ollama (check payload,
missing-solution 400, refine validation, phone + desktop check flows,
shorter follow-up with restore, guide explain, deck hand-off) plus
regression 74/74, mobile 52/52, declutter 116/116, wizard 80/80,
animation 8/8, native 35/35, phase1 38/38, phase2 43/43, i18n 25/25.
Key parity 734/734. Lint + build clean. SW freezmo-v27.
This commit is contained in:
Martin Dvořák 2026-10-10 14:36:04 +00:00
commit 4513364019
22 changed files with 947 additions and 56 deletions

View file

@ -4,7 +4,7 @@ import { motion, useReducedMotion } from "framer-motion";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
import { CameraIcon, CheckCircleIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { WizardActionBar } from "@/components/WizardActionBar";
import {
@ -47,6 +47,7 @@ function describeExtraction(
const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [
{ id: "make", icon: <SparklesIcon className="size-5" />, titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" },
{ id: "guide", icon: <PlayIcon className="size-5" />, titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" },
{ id: "check", icon: <CheckCircleIcon className="size-5" />, titleKey: "homework.mode.check", subtitleKey: "homework.mode.checkDesc" },
];
export function HomeworkInput({
@ -54,6 +55,8 @@ export function HomeworkInput({
onModeChange,
text,
onTextChange,
solution,
onSolutionChange,
intake,
onSubmit,
error,
@ -65,6 +68,9 @@ export function HomeworkInput({
onModeChange: (mode: HomeworkMode) => void;
text: string;
onTextChange: (text: string) => void;
/** Mode "check": the student's own attempt. */
solution: string;
onSolutionChange: (solution: string) => void;
intake: ReturnType<typeof useNotesIntake>;
/** `modeOverride` lets the phone mode cards start the run they tapped. */
onSubmit: (modeOverride?: HomeworkMode) => void;
@ -80,6 +86,8 @@ export function HomeworkInput({
const [step, setStep] = useState(1);
const [stepDir, setStepDir] = useState<1 | -1>(1);
const [pasteOpen, setPasteOpen] = useState(false);
/** Phones: the check card reveals the solution field instead of starting. */
const [checkOpen, setCheckOpen] = useState(false);
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
const online = useOnline();
@ -176,6 +184,29 @@ export function HomeworkInput({
</div>
) : null;
/* Shared solution field: typed/pasted only (documented in the note). */
const solutionField = (
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-[var(--text)]" htmlFor="homework-solution">
{t("homework.solution.label")}
</label>
<textarea
id="homework-solution"
value={solution}
onChange={(event) => onSolutionChange(event.target.value)}
rows={5}
placeholder={t("homework.solution.placeholder")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("homework.solution.note")}</p>
</div>
);
/* Shared mode card: desktop toggles the selection, the phone flow starts it. */
const modeCard = (option: (typeof MODES)[number], onPick: () => void) => {
const selected = mode === option.id;
@ -479,12 +510,35 @@ export function HomeworkInput({
{MODES.map((option) =>
modeCard(option, () => {
if (!canSubmit || !online) return;
if (option.id === "check") {
// Needs the solution first: reveal the field instead of
// starting the run.
onModeChange("check");
setCheckOpen(true);
return;
}
clearWizardDraft("homework");
onModeChange(option.id);
onSubmit(option.id);
}),
)}
</div>
{checkOpen ? (
<div className="space-y-3">
{solutionField}
<Button
size="lg"
className="touch-target w-full"
disabled={!canSubmit || !solution.trim()}
onClick={() => {
clearWizardDraft("homework");
onSubmit("check");
}}
>
{t("homework.mode.check")}
</Button>
</div>
) : null}
<p className="text-center text-[12px] text-[var(--text-subtle)]">
{online ? t("homework.phone.startHint") : t("offline.generate")}
</p>
@ -704,16 +758,23 @@ export function HomeworkInput({
{/* ------------------------------------------------------- mode */}
<section className="space-y-3">
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-3">
{MODES.map((option) => modeCard(option, () => onModeChange(option.id)))}
</div>
{mode === "check" ? solutionField : null}
</section>
{/* -------------------------------------------------------- CTA */}
<div className="space-y-2">
<Button size="lg" className="touch-target w-full" onClick={() => onSubmit()} disabled={!canSubmit} loading={intake.busy}>
<Button
size="lg"
className="touch-target w-full"
onClick={() => onSubmit()}
disabled={!canSubmit || (mode === "check" && !solution.trim())}
loading={intake.busy}
>
<SparklesIcon className="size-5" />
{mode === "make" ? t("homework.start.make") : t("homework.start.guide")}
{mode === "make" ? t("homework.start.make") : mode === "guide" ? t("homework.start.guide") : t("homework.mode.check")}
</Button>
<p className="text-center text-[12px] text-[var(--text-subtle)]">
{canSubmit ? t("homework.source.ready") : t("homework.source.need")}