One-pill study chrome, toggling chat pickers, readable homework steps

UI batch from feedback:

- The study/review/exam/homework chrome is now a single 58px pill: round
  close button, title + position over the progress bar, chips (or the exam
  timer) at the far end — everything on the centre line, so nothing crowds
  the rounded corners any more. Homework guide gets the same recipe.
- The immersive feeds carry z-[45] so they paint over the app chrome (on
  /exam the header used to cover the top of the chrome), and the page chrome
  behind an open overlay (.app-header, .segmented, .tabbar) is hidden while
  the transparent overlay is up, so it can no longer ghost through the glass.
  The guide's disclaimer moved inside the overlay with it.
- Chat: the context and model chips toggle their own popover (pressed state,
  aria-expanded), opening one closes the other, a press outside closes it —
  no more being forced to pick something to get out.
- Homework: the model's plain-text step explanations/hints/verdicts keep
  their line breaks (whitespace-pre-wrap) — a step used to collapse into one
  run-on paragraph with the whole solution inside it; the guide prompt now
  says the explanation is coaching, 2-4 sentences.
- The four glacier blobs are at 25% of their old opacity (0.045 light,
  0.1 dark).

Tests: phase-2 chrome geometry + exam layering checks (62), chat desktop
picker checks (101), the standing suites all green. README: decisions 54-56,
decision 23 updated. SW -> freezmo-v44.
This commit is contained in:
Martin Dvořák 2026-10-12 14:21:11 +00:00
commit 9d204b85be
16 changed files with 314 additions and 118 deletions

View file

@ -1061,7 +1061,9 @@ Things I chose while building, with the reasoning:
rendered inside the content column, so on wide screens it was clipped into a visible hard edge. rendered inside the content column, so on wide screens it was clipped into a visible hard edge.
It now lives in a single `position: fixed` layer (`.ice-scene`, `z-index: -1`, pointer-events It now lives in a single `position: fixed` layer (`.ice-scene`, `z-index: -1`, pointer-events
none): base gradient, four drifting glacier blobs, five glossy ice orbs and an SVG-noise grain. none): base gradient, four drifting glacier blobs, five glossy ice orbs and an SVG-noise grain.
`prefers-reduced-motion` stops the drift. `prefers-reduced-motion` stops the drift. The four blobs were then turned down to a quarter of
their opacity (0.18 → 0.045 light, 0.40 → 0.10 dark): at full strength the huge circles read
as UI rather than as backdrop.
24. **The UI was deliberately made quieter.** One accent colour (`accent-*`), a desaturated 24. **The UI was deliberately made quieter.** One accent colour (`accent-*`), a desaturated
`frost-*` scale, hairline borders and flat surfaces replaced the saturated gradients and the `frost-*` scale, hairline borders and flat surfaces replaced the saturated gradients and the
seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry
@ -1359,6 +1361,33 @@ Things I chose while building, with the reasoning:
phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a
monthly-limit body, a two-device sync round-trip, and the generation guards below). monthly-limit body, a two-device sync round-trip, and the generation guards below).
56. **Model text keeps its own line breaks.** Every homework prompt asks for plain text with short
lines and numbered or dashed lists (`HOMEWORK_BASE_RULES`), but the guide rendered the step
explanation in a bare `<p>` — JSX collapses newlines, so a step arrived as one run-on paragraph
with the whole worked solution inside it (the reported "completely broken" step). The
explanation, the progressive hints and the verdict/wrong entries of a check now render with
`whitespace-pre-wrap` like the other homework results always did, and the guide prompt says the
explanation is 2-4 sentences of coaching, with the finished working left to hint 3 and
`answer`.
55. **Popovers close the way they open.** The chat's context ("what may chat read") and model chips
used to only ever open: the sole way out was picking something, or Escape. The chip now toggles
its own popover (and reads pressed while open, `aria-expanded`), opening one closes the other,
and on desktop a pointer press anywhere outside closes it — except on the trigger chips
themselves, which the outside handler ignores precisely so the toggle cannot re-open what it
just closed. Phones keep the sheet + backdrop they already had. Verified by the chat suite
(toggle, pressed state, switch, outside click).
54. **The study chrome is one pill, and it sits above the app chrome.** Progress header, close
button, title, position and streak/xp (or the exam timer) used to be two stacked rows inside a
`rounded-full` box — on a tall box that is a stadium, so the corner curves cut into the icon
row and the controls looked like they were falling into the corners. It is now a single 58px
pill: round close button, title + counter over the progress bar in the middle, chips at the far
end, everything on the centre line — the same recipe in the study feed, review/exam sessions and
the homework guide. The immersive feeds also carry `z-[45]`: on `/exam` the app header (`z-40`)
used to paint over the top of the chrome. Verified by the phase-2 suite (the close button's
corners stay inside the rounded end, single row, counter present).
53. **The flashcard flip never relies on `backface-visibility`.** The card is two faces in a 53. **The flashcard flip never relies on `backface-visibility`.** The card is two faces in a
`preserve-3d` container (the back pre-rotated 180°) — the classic CSS flip — but each face is a `preserve-3d` container (the back pre-rotated 180°) — the classic CSS flip — but each face is a
glass layer with `backdrop-filter`, and on some GPU/compositing paths the front's backface keeps glass layer with `backdrop-filter`, and on some GPU/compositing paths the front's backface keeps

View file

@ -173,18 +173,21 @@
--shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.55), 0 2px 8px rgb(0 0 0 / 0.3); --shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.55), 0 2px 8px rgb(0 0 0 / 0.3);
} }
/* Ice scene tokens: the fixed layered background behind everything. */ /* Ice scene tokens: the fixed layered background behind everything.
The four glacier blobs are deliberately whisper-faint now: at 0.18/0.40
they competed with the content (the "huge circles" read as part of the
UI). 25% of the old values. */
:root { :root {
--ice-base-top: #f4f9ff; --ice-base-top: #f4f9ff;
--ice-base-bottom: #e6f1fb; --ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.18; --ice-blob-opacity: 0.045;
--ice-orb-opacity: 0.34; --ice-orb-opacity: 0.34;
--ice-grain-opacity: 0.05; --ice-grain-opacity: 0.05;
} }
.dark { .dark {
--ice-base-top: #070b16; --ice-base-top: #070b16;
--ice-base-bottom: #0c1426; --ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.4; --ice-blob-opacity: 0.1;
--ice-orb-opacity: 0.5; --ice-orb-opacity: 0.5;
--ice-grain-opacity: 0.045; --ice-grain-opacity: 0.045;
} }
@ -217,6 +220,14 @@ body {
background: transparent; background: transparent;
} }
/* A full-screen overlay (study feed, review/exam session, homework guide) is
transparent on purpose so the fixed ice scene stays visible — which also
means the app chrome of the page behind it would ghost through the glass.
While one is open, that chrome is hidden instead. */
body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
visibility: hidden;
}
/* The key gate rides on the shared ice scene (see .ice-scene). */ /* The key gate rides on the shared ice scene (see .ice-scene). */
/* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice /* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice

View file

@ -43,7 +43,7 @@ export function AppHeader({ className, compact = false }: { className?: string;
// No negative margins here: the header is a direct child of <main>, // No negative margins here: the header is a direct child of <main>,
// which has no horizontal padding — the -mx-4 made it 16px wider // which has no horizontal padding — the -mx-4 made it 16px wider
// than the viewport and caused a horizontal scroll on phones. // than the viewport and caused a horizontal scroll on phones.
"glass sticky top-0 z-40 mb-6 px-4 lg:hidden sm:px-6", "glass app-header sticky top-0 z-40 mb-6 px-4 lg:hidden sm:px-6",
className, className,
)} )}
> >

View file

@ -144,43 +144,49 @@ export function SessionFeed({
const score = attemptedTotal > 0 ? Math.round((correct / attemptedTotal) * 100) : 0; const score = attemptedTotal > 0 ? Math.round((correct / attemptedTotal) * 100) : 0;
return ( return (
<div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile"> <div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
{/* Same pill as the study feed: round close button, title + position
above the bar, timer at the other end (nothing crowds the ends). */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3"> <header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2"> <div className="study-chrome mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2">
<div className="flex items-center gap-3"> <button
<button type="button"
type="button" onClick={onExit}
onClick={onExit} aria-label={t("feed.back")}
aria-label={t("feed.back")} title={t("feed.back")}
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]" 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" /> <XIcon className="size-4" />
</button> </button>
<div className="min-w-0 flex-1">
<ProgressBar <div className="min-w-0 flex-1">
value={Math.min(index + 1, slideCount)} <div className="flex items-baseline justify-between gap-3">
max={slideCount} <span className="min-w-0 truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
label={t("feed.activityOf", { n: Math.min(index + 1, total), total })} {title}
/>
</div>
{timerSeconds ? (
<span
className={cn(
"shrink-0 rounded-xl px-2 py-1 text-xs font-extrabold tabular-nums",
remaining <= 30 ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
aria-label={t("exam.timeLeft", { time: formatClock(remaining) })}
>
{formatClock(remaining)}
</span> </span>
) : null} <span className="shrink-0 text-[11.5px] font-bold tabular-nums text-[var(--text-muted)]">
{Math.min(index + 1, total)}/{total}
</span>
</div>
<ProgressBar
className="mt-1.5"
value={Math.min(index + 1, slideCount)}
max={slideCount}
label={t("feed.activityOf", { n: Math.min(index + 1, total), total })}
/>
</div> </div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
<span className="min-w-0 truncate">{title}</span> {timerSeconds ? (
<span className="shrink-0 tabular-nums"> <span
{Math.min(index + 1, total)}/{total} className={cn(
"shrink-0 rounded-full px-3 py-1 text-xs font-extrabold tabular-nums",
remaining <= 30 ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
aria-label={t("exam.timeLeft", { time: formatClock(remaining) })}
>
{formatClock(remaining)}
</span> </span>
</div> ) : null}
</div> </div>
</header> </header>

View file

@ -238,54 +238,61 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const progressMax = slideCount; const progressMax = slideCount;
return ( return (
<div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile"> <div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col fade-in-mobile">
{/* Chrome ---------------------------------------------------- */} {/* Chrome ---------------------------------------------------- */}
{/* One pill: round close button, title + position above the bar, and
the streak/xp chips at the other end. Everything sits on the pill's
centre line with even padding, so nothing crowds the rounded ends. */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3"> <header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2"> <div className="study-chrome mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2">
<div className="flex items-center gap-3"> <Link
<Link href="/"
href="/" aria-label={t("feed.back")}
aria-label={t("feed.back")} title={t("feed.back")}
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]" 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" /> <XIcon className="size-4" />
</Link> </Link>
<div className="min-w-0 flex-1">
<ProgressBar <div className="min-w-0 flex-1">
value={progressValue} <div className="flex items-baseline justify-between gap-3">
max={progressMax} <span className="min-w-0 truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
label={t("feed.activityOf", { n: progressValue, total: progressMax })} {currentDeck.title}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span
className={cn(
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs font-extrabold transition",
streak >= 3
? "tint-peach"
: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
title={t("feed.streak", { n: streak })}
aria-label={t("feed.streak", { n: streak })}
>
<FlameIcon className="size-3.5" />
{streak}
</span> </span>
<span <span className="shrink-0 text-[11.5px] font-bold tabular-nums text-[var(--text-muted)]">
className="inline-flex items-center gap-1 rounded-xl bg-[var(--surface-sunken)] px-2 py-1 text-xs font-extrabold text-[var(--text-muted)]" {retryMode ? t("feed.retry") : null}
title={t("feed.xp", { n: xp })} {Math.min(index + 1, total)}/{total}
aria-label={t("feed.xp", { n: xp })}
>
<BoltIcon className="size-3.5" />
{xp}
</span> </span>
</div> </div>
<ProgressBar
className="mt-1.5"
value={progressValue}
max={progressMax}
label={t("feed.activityOf", { n: progressValue, total: progressMax })}
/>
</div> </div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
<span className="min-w-0 truncate">{currentDeck.title}</span> <div className="flex shrink-0 items-center gap-1.5">
<span className="shrink-0"> <span
{retryMode ? t("feed.retry") : ""} className={cn(
{Math.min(index + 1, total)}/{total} "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold tabular-nums transition",
streak >= 3
? "tint-peach"
: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
title={t("feed.streak", { n: streak })}
aria-label={t("feed.streak", { n: streak })}
>
<FlameIcon className="size-3.5" />
{streak}
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-xs font-extrabold tabular-nums text-[var(--text-muted)]"
title={t("feed.xp", { n: xp })}
aria-label={t("feed.xp", { n: xp })}
>
<BoltIcon className="size-3.5" />
{xp}
</span> </span>
</div> </div>
</div> </div>

View file

@ -681,8 +681,18 @@ export function ChatScreen() {
online={online} online={online}
scopeLabel={scopeText} scopeLabel={scopeText}
modelLabel={activeModel || t("chat.modelTitle")} modelLabel={activeModel || t("chat.modelTitle")}
onOpenScope={() => setScopeOpen(true)} scopeOpen={scopeOpen}
onOpenModel={() => setModelOpen(true)} modelOpen={modelOpen}
// The chips toggle their own popover (and open one at a time), so a
// second tap closes instead of forcing a pick.
onOpenScope={() => {
setScopeOpen((open) => !open);
setModelOpen(false);
}}
onOpenModel={() => {
setModelOpen((open) => !open);
setScopeOpen(false);
}}
onAttachFile={(file) => void handleAttach(file)} onAttachFile={(file) => void handleAttach(file)}
attachBusy={attachBusy} attachBusy={attachBusy}
attachments={attachments} attachments={attachments}

View file

@ -26,6 +26,8 @@ export function Composer({
online, online,
scopeLabel, scopeLabel,
modelLabel, modelLabel,
scopeOpen,
modelOpen,
onOpenScope, onOpenScope,
onOpenModel, onOpenModel,
onAttachFile, onAttachFile,
@ -41,6 +43,8 @@ export function Composer({
online: boolean; online: boolean;
scopeLabel: string; scopeLabel: string;
modelLabel: string; modelLabel: string;
scopeOpen: boolean;
modelOpen: boolean;
onOpenScope: () => void; onOpenScope: () => void;
onOpenModel: () => void; onOpenModel: () => void;
onAttachFile: (file: File) => void; onAttachFile: (file: File) => void;
@ -156,16 +160,22 @@ export function Composer({
<div className="mt-2 flex items-center gap-2 overflow-x-auto"> <div className="mt-2 flex items-center gap-2 overflow-x-auto">
<button <button
type="button" type="button"
data-picker-trigger
aria-haspopup="dialog"
aria-expanded={scopeOpen}
onClick={onOpenScope} onClick={onOpenScope}
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]" className={pickerChipClass(scopeOpen)}
> >
<LayersIcon className="size-3.5 shrink-0" /> <LayersIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{scopeLabel}</span> <span className="max-w-[170px] truncate">{scopeLabel}</span>
</button> </button>
<button <button
type="button" type="button"
data-picker-trigger
aria-haspopup="dialog"
aria-expanded={modelOpen}
onClick={onOpenModel} onClick={onOpenModel}
className="flex min-w-0 items-center gap-1.5 rounded-xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)]" className={pickerChipClass(modelOpen)}
> >
<ChatIcon className="size-3.5 shrink-0" /> <ChatIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{modelLabel}</span> <span className="max-w-[170px] truncate">{modelLabel}</span>
@ -181,3 +191,16 @@ export function Composer({
</div> </div>
); );
} }
/**
* Chip style for the two popover triggers (context, model). The open state
* reads pressed, because the same chip opens and closes its popover.
*/
function pickerChipClass(open: boolean): string {
return cn(
"flex min-w-0 items-center gap-1.5 rounded-xl border px-2.5 py-1.5 text-[12px] font-medium transition",
open
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]"
: "border-[var(--hairline-soft)] bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]",
);
}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons"; import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
@ -34,6 +34,7 @@ export function ContextPicker({
const { t, plural } = useI18n(); const { t, plural } = useI18n();
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const panel = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -44,6 +45,21 @@ export function ContextPicker({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
/* Desktop dropdown: a press anywhere outside closes it, except on the
trigger chips, which toggle their own popover (see Composer). */
useEffect(() => {
if (!open || isPhone) return;
const onDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (panel.current?.contains(target)) return;
if (target.closest("[data-picker-trigger]")) return;
onClose();
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, isPhone, onClose]);
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>(); const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
const choose = (next: ChatScope) => { const choose = (next: ChatScope) => {
@ -207,6 +223,7 @@ export function ContextPicker({
<AnimatePresence> <AnimatePresence>
{open ? ( {open ? (
<motion.div <motion.div
ref={panel}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon } from "@/components/icons"; import { CheckCircleIcon } from "@/components/icons";
@ -31,6 +31,7 @@ export function ModelPicker({
const { t } = useI18n(); const { t } = useI18n();
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const panel = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -41,6 +42,21 @@ export function ModelPicker({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
/* Desktop dropdown: a press anywhere outside closes it, except on the
trigger chips, which toggle their own popover (see Composer). */
useEffect(() => {
if (!open || isPhone) return;
const onDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (panel.current?.contains(target)) return;
if (target.closest("[data-picker-trigger]")) return;
onClose();
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, isPhone, onClose]);
const badge = (model: ChatModelChoice) => const badge = (model: ChatModelChoice) =>
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText"); model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
@ -123,6 +139,7 @@ export function ModelPicker({
<AnimatePresence> <AnimatePresence>
{open ? ( {open ? (
<motion.div <motion.div
ref={panel}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}

View file

@ -42,7 +42,7 @@ export function HomeworkCheckResult({
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]"> <p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("homework.check.verdict")} {t("homework.check.verdict")}
</p> </p>
<p className="text-[15px] leading-relaxed text-[var(--text)]">{record.verdict}</p> <p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{record.verdict}</p>
</section> </section>
{record.correct.length > 0 ? ( {record.correct.length > 0 ? (
@ -66,10 +66,10 @@ export function HomeworkCheckResult({
<ul className="mt-2 space-y-3"> <ul className="mt-2 space-y-3">
{record.wrong.map((entry, index) => ( {record.wrong.map((entry, index) => (
<li key={index} className="space-y-0.5 text-[14px] leading-relaxed text-rose-900 dark:text-rose-100"> <li key={index} className="space-y-0.5 text-[14px] leading-relaxed text-rose-900 dark:text-rose-100">
<p className="font-semibold">{entry.issue}</p> <p className="whitespace-pre-wrap break-words font-semibold">{entry.issue}</p>
<p>{entry.why}</p> <p className="whitespace-pre-wrap break-words">{entry.why}</p>
{entry.fix ? ( {entry.fix ? (
<p> <p className="whitespace-pre-wrap break-words">
<span className="font-bold">{t("homework.check.fix")}: </span> <span className="font-bold">{t("homework.check.fix")}: </span>
{entry.fix} {entry.fix}
</p> </p>

View file

@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities"; import { ActivityRenderer } from "@/components/activities";
import { PromptCard, QuestionText } from "@/components/activities/shared"; import { PromptCard, QuestionText } from "@/components/activities/shared";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { XIcon } from "@/components/icons"; import { XIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui"; import { Button, ProgressBar } from "@/components/ui";
import { AskButton } from "@/components/chat/AskButton"; import { AskButton } from "@/components/chat/AskButton";
@ -138,32 +139,37 @@ export function HomeworkGuide({
}; };
return ( return (
<div className="frost-bg fixed inset-0 z-[45] flex flex-col"> <div className="frost-bg overlay-open fixed inset-0 z-[45] flex flex-col">
{/* chrome ------------------------------------------------------- */} {/* 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"> <header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2"> <div className="study-chrome mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2">
<div className="flex items-center gap-3"> <button
<button type="button"
type="button" onClick={onClose}
onClick={onClose} aria-label={t("homework.guide.close")}
aria-label={t("homework.guide.close")} title={t("homework.guide.close")}
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]" 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" /> <XIcon className="size-4" />
</button> </button>
<div className="min-w-0 flex-1">
<ProgressBar <div className="min-w-0 flex-1">
value={progressValue} <div className="flex items-baseline justify-between gap-3">
max={slideCount} <span className="min-w-0 truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
label={t("homework.guide.stepOf", { n: progressValue, total: slideCount })} {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> </div>
</div> <ProgressBar
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]"> className="mt-1.5"
<span className="min-w-0 truncate">{record.title}</span> value={progressValue}
<span className="shrink-0"> max={slideCount}
{onSolution ? t("homework.guide.solution") : t("homework.guide.stepOf", { n: index + 1, total: steps.length })} label={t("homework.guide.stepOf", { n: progressValue, total: slideCount })}
</span> />
</div> </div>
</div> </div>
</header> </header>
@ -189,7 +195,10 @@ export function HomeworkGuide({
<PromptCard> <PromptCard>
<QuestionText>{step.title}</QuestionText> <QuestionText>{step.title}</QuestionText>
<p className="mt-2 text-[15px] leading-relaxed text-frost-700 dark:text-frost-200"> {/* 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} {explained[stepIndex] ?? step.explanation}
</p> </p>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
@ -221,7 +230,7 @@ export function HomeworkGuide({
className="glass-chip rounded-2xl p-3 text-sm leading-relaxed text-[var(--text)]" 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="font-bold">{t("homework.guide.hintN", { n: hintIndex + 1 })}: </span>
{hint} <span className="whitespace-pre-wrap break-words">{hint}</span>
</motion.div> </motion.div>
))} ))}
{revealedCount(stepIndex) < step.hints.length ? ( {revealedCount(stepIndex) < step.hints.length ? (
@ -261,6 +270,7 @@ export function HomeworkGuide({
<span aria-hidden>→</span> <span aria-hidden>→</span>
</Button> </Button>
)} )}
<AiDisclaimer />
</div> </div>
</section> </section>
))} ))}
@ -282,6 +292,7 @@ export function HomeworkGuide({
</p> </p>
</PromptCard> </PromptCard>
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.guide.solutionNote")}</p> <p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.guide.solutionNote")}</p>
<AiDisclaimer />
<div className="flex flex-wrap items-start gap-2"> <div className="flex flex-wrap items-start gap-2">
<CopyButton text={solution} /> <CopyButton text={solution} />
<AskButton <AskButton

View file

@ -256,7 +256,9 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
againLoading={againLoading} againLoading={againLoading}
/> />
)} )}
<AiDisclaimer /> {/* The guide is a full-screen overlay and carries its own copy; this
page-flow one would ghost through the transparent overlay. */}
{view.record.mode === "guide" ? null : <AiDisclaimer />}
</div> </div>
); );
} }

View file

@ -254,7 +254,7 @@ Guide rules:
- 3 to 8 steps, in the order the student should do them, and the last step must actually finish the assignment. - 3 to 8 steps, in the order the student should do them, and the last step must actually finish the assignment.
- Each step has: - Each step has:
* "title": 2-6 words. * "title": 2-6 words.
* "explanation": what to do in this step AND why it matters, 2-4 sentences, spoken to the student ("First work out…", "Now check…"). * "explanation": what to do in this step AND why it matters, 2-4 sentences of coaching, spoken to the student ("First work out…", "Now check…"). Explain the move — do not work the whole assignment out here; the finished working belongs in hint 3 and "answer".
* "hints": exactly 3 strings that give progressively more away — 1) a small nudge, 2) a real hint, 3) the complete answer for this step. * "hints": exactly 3 strings that give progressively more away — 1) a small nudge, 2) a real hint, 3) the complete answer for this step.
* "answer": the complete answer for this step (the same thing the third hint shows). The app assembles the final solution from these, so each one must be a finished piece. * "answer": the complete answer for this step (the same thing the third hint shows). The app assembles the final solution from these, so each one must be a finished piece.
- Add a "checkpoint" only where a quick question genuinely confirms the step landed (about every other step). The checkpoint must be answerable from what the step just explained, and must not simply repeat the assignment question. Leave the field out entirely when it does not help. - Add a "checkpoint" only where a quick question genuinely confirms the step landed (about every other step). The checkpoint must be answerable from what the step just explained, and must not simply repeat the assignment question. Leave the field out entirely when it does not help.

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/ */
const CACHE_VERSION = "freezmo-v43"; const CACHE_VERSION = "freezmo-v44";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -289,6 +289,36 @@ try {
await desktop.page.getByRole("button", { name: "Expand sidebar" }).click(); await desktop.page.getByRole("button", { name: "Expand sidebar" }).click();
await desktop.page.waitForTimeout(400); await desktop.page.waitForTimeout(400);
check("desktop: AI disclaimer under the composer", await desktop.page.getByText("Freezmo uses AI and can make mistakes").isVisible()); check("desktop: AI disclaimer under the composer", await desktop.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
/* 6b. desktop pickers: the chip that opens also closes ---------------
(It used to only ever open, so the only way out was a choice.) */
const modelChip = () => desktop.page.getByRole("button", { name: /stub-(text|vision)|Chat model/ }).first();
const modelDialog = desktop.page.getByRole("dialog", { name: "Chat model" });
const scopeDialog = desktop.page.getByRole("dialog", { name: "What may chat read?" });
const scopeChip = () => desktop.page.getByRole("button", { name: "All notes" }).first();
await modelChip().click();
await desktop.page.waitForTimeout(400);
check("desktop: model chip opens the dropdown", (await modelDialog.count()) === 1);
check("desktop: model chip reads pressed while open", (await modelChip().getAttribute("aria-expanded")) === "true");
await modelChip().click();
await desktop.page.waitForTimeout(400);
check("desktop: the same chip closes it (no forced choice)", (await modelDialog.count()) === 0);
check("desktop: chip is not pressed once closed", (await modelChip().getAttribute("aria-expanded")) === "false");
await scopeChip().click();
await desktop.page.waitForTimeout(400);
check("desktop: note chip opens the dropdown", (await scopeDialog.count()) === 1);
await modelChip().click();
await desktop.page.waitForTimeout(400);
check(
"desktop: opening one picker closes the other",
(await scopeDialog.count()) === 0 && (await modelDialog.count()) === 1,
);
await desktop.page.mouse.click(700, 300);
await desktop.page.waitForTimeout(400);
check("desktop: a click outside closes the dropdown", (await modelDialog.count()) === 0);
await desktop.context.close(); await desktop.context.close();
const phone = await makePage(browser); const phone = await makePage(browser);

View file

@ -33,6 +33,29 @@ const browser = await launchBrowser();
await seed(page, { decks: [DECK] }); await seed(page, { decks: [DECK] });
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
await page.waitForTimeout(900); await page.waitForTimeout(900);
/* 0. the study chrome is one pill that its controls fit -------------- */
const chrome = await page.evaluate(() => {
const pill = document.querySelector("header .study-chrome");
if (!pill) return null;
const box = pill.getBoundingClientRect();
const radius = Number.parseFloat(getComputedStyle(pill).borderTopLeftRadius) || 0;
const control = pill.querySelector("a, button").getBoundingClientRect();
const centreY = box.top + box.height / 2;
return {
height: Math.round(box.height),
fullyRounded: radius >= box.height / 2 - 1,
/** The control's far corner stays inside the stadium curve. */
cornerInside: Math.hypot(control.left - (box.left + box.height / 2), control.top - centreY) < box.height / 2,
counter: /\d+\s*\/\s*\d+/.test(pill.textContent ?? ""),
};
});
check(
"chrome: one rounded pill, controls inside its ends",
chrome !== null && chrome.fullyRounded && chrome.cornerInside && chrome.counter && chrome.height <= 70,
JSON.stringify(chrome),
);
await page.getByRole("button", { name: "Green" }).click(); await page.getByRole("button", { name: "Green" }).click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
check("study: wrong answer shows feedback", await page.getByText(/Correct answer/i).first().isVisible()); check("study: wrong answer shows feedback", await page.getByText(/Correct answer/i).first().isVisible());
@ -351,6 +374,16 @@ const browser = await launchBrowser();
await page.getByRole("button", { name: /Start exam/ }).click(); await page.getByRole("button", { name: /Start exam/ }).click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
check("exam: timer visible", await page.getByLabel(/left$/).isVisible()); check("exam: timer visible", await page.getByLabel(/left$/).isVisible());
/* The exam runs inside the app layout: the fixed feed (z-[45]) must sit
over the sticky app header (z-40), not under it. */
const examChrome = await page.evaluate(() => {
const pill = document.querySelector("header .study-chrome");
if (!pill) return null;
const box = pill.getBoundingClientRect();
const atTop = document.elementFromPoint(Math.round(box.left + box.width / 2), Math.round(box.top + 4));
return Boolean(atTop?.closest(".study-chrome"));
});
check("exam: chrome paints over the app header", examChrome === true, String(examChrome));
check("exam: no hint rows", (await page.getByText(/Tap a term|Shortcuts|Use the arrow/i).count()) === 0); check("exam: no hint rows", (await page.getByText(/Tap a term|Shortcuts|Use the arrow/i).count()) === 0);
// Order-independent: MC listens for number keys on the active slide and the // Order-independent: MC listens for number keys on the active slide and the
// feed for ArrowDown. Both activities have the correct answer at index 0. // feed for ArrowDown. Both activities have the correct answer at index 0.