freezmo/components/ResultsScreen.tsx
pepaskopec-blip bfc0f806b9 The scene stops heating the MacBook
"Terribly high CPU usage, the MacBook gets really hot" - a fanless MacBook
Air on a 144Hz screen. Measured on that machine with headed engines
(process CPU plus the per-process GPU time macOS keeps): an idle Aero
library cost LibreWolf ~110% CPU and ~230ms/s of GPU, the study feed ~130%,
Chromium up to ~57% CPU and ~350ms/s of GPU, Safari ~220-250ms/s of
WindowServer GPU. Priced one by one:

- the gradient mask on .ice-floats re-rendered the animated layer offscreen
  on every frame (~75 points of Gecko CPU). The floats now rise through a
  plain rectangular band clear of the header, the bottom chrome and the
  rail, and fade in and out inside it, so the clip never cuts a visible one;
- the overlay grain forced a backdrop readback per frame for a texture that
  measured a mean <=0.11/255 difference: removed;
- the invisible blob drift made every frame a full-screen redraw: the blobs
  are painted once;
- static glass (answer tiles, feedback panel, chips, inputs, dropzones,
  lists, segmented controls, Aero hint chips) re-blurred the moving scene
  every frame: only floating glass blurs now, and Aero's baked chat chrome
  drops a live blur that never showed under the bake;
- the scene also parks while the window has no focus and under any dialog
  or sheet scrim (.scene-pause); the loading icon is a compositor-run CSS
  animation instead of a framer-motion main-thread loop.

After, focused and animating: LibreWolf library 109% -> 33% CPU, study
134% -> 34%, chat 130% -> 42%; Chromium GPU 2.5-4.4x lower with 7-22 points
less CPU; Safari's WindowServer GPU -20-26% in Aero, about half in dark.
Unfocused or under a sheet: nothing. bubbles-test sweeps every float through
41 phases against the band and pins the parking and the cost contract;
tests/heat-probe.mjs (macOS) repeats the measurement; the suites find
Playwright's Chromium on macOS.

All 15 suites pass, lint clean, the contrast scan lost a finding. Version
0.6.7, SW v89.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 19:42:29 +02:00

302 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { useI18n } from "@/lib/i18n";
import { useCountUp } from "@/lib/motion";
import type { Activity, Deck } from "@/lib/schemas";
import { downloadDeck } from "@/lib/storage";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
import {
BoltIcon,
DownloadIcon,
FlameIcon,
HistoryIcon,
HomeIcon,
RefreshIcon,
SnowflakeIcon,
SparklesIcon,
} from "@/components/icons";
import { Button, Chip } from "@/components/ui";
import { AskButton } from "@/components/chat/AskButton";
import type { AttemptResult } from "./StudyFeed";
type Translator = (key: string, vars?: Record<string, string | number>) => string;
export function answerFor(activity: Activity, tr: Translator): string {
switch (activity.type) {
case "flashcard":
return activity.back;
case "multiple_choice":
return activity.options[activity.correctIndex];
case "true_false":
return activity.answer ? tr("results.true") : tr("results.false");
case "fill_blank":
return activity.answer;
case "matching":
return activity.pairs.map((pair) => `${pair.term} → ${pair.definition}`).join("; ");
case "ordering":
return activity.items.map((item, index) => `${index + 1}. ${item}`).join(" ");
case "short_answer":
return activity.referenceAnswer;
}
}
export function promptFor(activity: Activity): string {
switch (activity.type) {
case "flashcard":
return activity.front;
case "multiple_choice":
return activity.question;
case "true_false":
return activity.statement;
case "fill_blank":
return activity.sentence.replace(/_{2,}/, "____");
case "matching":
return activity.instruction;
case "ordering":
return activity.instruction;
case "short_answer":
return activity.question;
}
}
export function ResultsScreen({
deck,
xp,
bestStreak,
total,
correct,
missed,
unanswered,
results,
retryMode,
onRetryMissed,
onStudyUnanswered,
onRestart,
onRegenerate,
regenerating,
regenerateError,
}: {
deck: Deck;
xp: number;
bestStreak: number;
/** Cards that were answered (right or wrong). */
total: number;
correct: number;
/** Answered, but wrong. */
missed: Activity[];
/** Scrolled past without answering — waiting, not wrong. */
unanswered: Activity[];
results: Record<string, AttemptResult>;
retryMode: boolean;
onRetryMissed: () => void;
onStudyUnanswered: () => void;
onRestart: () => void;
onRegenerate: () => void;
regenerating: boolean;
regenerateError: string | null;
}) {
const { t } = useI18n();
const isPhone = useIsPhone();
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
/* Phones: the score and XP count up; desktop jumps straight in. */
const percentShown = useCountUp(percent, !isPhone);
const xpShown = useCountUp(xp, !isPhone);
const perfect = missed.length === 0 && unanswered.length === 0 && total > 0;
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
return (
<div className="space-y-4">
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
className="card flex flex-col items-center gap-3 p-6 text-center"
>
<ScoreRing percent={percent} display={percentShown} tone={tone} />
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
{perfect ? t("results.perfect") : percent >= 60 ? t("results.nice") : t("results.start")}
</h1>
<p className="text-sm text-frost-600 dark:text-frost-300">
{perfect
? t("results.acedAll", { n: total, title: deck.title })
: total === 0
? t("results.noneAnswered")
: t("results.gotOf", {
correct,
total,
retry: retryMode ? t("results.retrySuffix") : "",
})}
</p>
<div className="mt-1 flex flex-wrap justify-center gap-2">
<Chip tone="accent">
<BoltIcon className="size-3.5" /> {t("results.xpEarned", { n: xpShown })}
</Chip>
<Chip tone="warning">
<FlameIcon className="size-3.5" /> {t("results.bestStreak", { n: bestStreak })}
</Chip>
<Chip tone="neutral">
{t("results.difficulty", {
level:
deck.difficulty === "easy"
? t("upload.easy")
: deck.difficulty === "hard"
? t("upload.hard")
: t("upload.medium"),
})}
</Chip>
{deck.bestScore !== undefined && !retryMode ? (
<Chip tone="success">{t("results.best", { n: deck.bestScore })}</Chip>
) : null}
</div>
</motion.div>
{missed.length > 0 ? (
<div className="card space-y-3 p-5">
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
<SnowflakeIcon className="size-4" /> {t("results.missed", { n: missed.length })}
</h2>
<ul className="space-y-3">
{missed.map((activity) => (
<li
key={activity.id}
className="rounded-2xl border border-white/50 bg-white/45 p-3 text-sm dark:border-white/10 dark:bg-white/5"
>
<p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p>
{results[activity.id]?.userAnswer ? (
<p className="mt-1 text-xs text-rose-600 dark:text-rose-300">
{t("results.yourAnswer", { answer: results[activity.id]?.userAnswer ?? "" })}
</p>
) : null}
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-300">
<span className="font-bold">{t("results.answer")} </span>
{answerFor(activity, t)}
</p>
</li>
))}
</ul>
</div>
) : null}
{unanswered.length > 0 ? (
<div className="card space-y-3 p-5">
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
<HistoryIcon className="size-4" /> {t("results.unanswered", { n: unanswered.length })}
</h2>
<p className="text-xs leading-relaxed text-frost-600 dark:text-frost-300">{t("results.unansweredNote")}</p>
<ul className="space-y-2">
{unanswered.map((activity) => (
<li
key={activity.id}
className="rounded-2xl border border-white/50 bg-white/45 p-3 text-sm font-semibold text-frost-950 dark:border-white/10 dark:bg-white/5 dark:text-frost-50"
>
{promptFor(activity)}
</li>
))}
</ul>
</div>
) : null}
<div className="flex flex-col gap-2">
{missed.length > 0 ? (
<Button size="lg" onClick={onRetryMissed}>
<RefreshIcon className="size-5" /> {t("results.retryMissed", { n: missed.length })}
</Button>
) : null}
{unanswered.length > 0 ? (
<Button size="lg" variant={missed.length > 0 ? "secondary" : "primary"} onClick={onStudyUnanswered}>
<RefreshIcon className="size-5" /> {t("results.retryUnanswered", { n: unanswered.length })}
</Button>
) : null}
{missed.length === 0 && unanswered.length === 0 ? (
<Button size="lg" variant="secondary" onClick={onRestart}>
<RefreshIcon className="size-5" /> {t("results.studyAgain")}
</Button>
) : (
<Button size="lg" variant="secondary" onClick={onRestart}>
<RefreshIcon className="size-5" /> {t("results.studyWhole")}
</Button>
)}
<Button
size="lg"
variant="secondary"
onClick={onRegenerate}
loading={regenerating}
disabled={regenerating}
>
<SparklesIcon className="size-5" />
{regenerating ? t("results.regenerating") : t("results.regenerate")}
</Button>
{regenerateError ? (
<p className="text-center text-xs text-rose-600 dark:text-rose-300" role="alert">
{regenerateError}
</p>
) : null}
<AskButton
className="w-full"
seed={{
deckId: deck.id,
label: t("chat.seedResults"),
text: [
`${t("chat.seedResults")}: ${deck.title} — ${t("results.gotOf", { correct, total, retry: "" })}`,
...missed.map((activity) => `${promptFor(activity)} — ${t("results.answer")} ${answerFor(activity, t)}`),
].join("\n"),
}}
/>
<div className="grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
<DownloadIcon className="size-4" /> {t("results.export")}
</Button>
<Link
href="/"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white/80 px-4 py-2.5 text-sm font-semibold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100 dark:hover:bg-white/20"
>
<HomeIcon className="size-4" /> {t("common.library")}
</Link>
</div>
</div>
</div>
);
}
function ScoreRing({ percent, display, tone }: { percent: number; display: number; tone: "emerald" | "frost" | "amber" }) {
const { t } = useI18n();
const radius = 52;
const circumference = 2 * Math.PI * radius;
const offset = circumference * (1 - Math.min(100, Math.max(0, percent)) / 100);
const colors: Record<string, string> = {
emerald: "stroke-emerald-500",
frost: "stroke-accent-500",
amber: "stroke-amber-500",
};
return (
<div className="relative grid size-32 place-items-center">
<svg viewBox="0 0 120 120" className="size-32 -rotate-90" aria-hidden>
<circle cx="60" cy="60" r={radius} className="fill-none stroke-[var(--surface-sunken)]" strokeWidth="10" />
<motion.circle
cx="60"
cy="60"
r={radius}
strokeWidth="10"
strokeLinecap="round"
className={cn("fill-none", colors[tone])}
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 1, ease: "easeOut" }}
/>
</svg>
<div className="absolute flex flex-col items-center">
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{display}%</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
{t("results.score")}
</span>
</div>
</div>
);
}