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:
parent
33c0ad1ed0
commit
9d204b85be
16 changed files with 314 additions and 118 deletions
|
|
@ -43,7 +43,7 @@ export function AppHeader({ className, compact = false }: { className?: string;
|
|||
// No negative margins here: the header is a direct child of <main>,
|
||||
// which has no horizontal padding — the -mx-4 made it 16px wider
|
||||
// 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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -144,43 +144,49 @@ export function SessionFeed({
|
|||
const score = attemptedTotal > 0 ? Math.round((correct / attemptedTotal) * 100) : 0;
|
||||
|
||||
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">
|
||||
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
aria-label={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)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProgressBar
|
||||
value={Math.min(index + 1, slideCount)}
|
||||
max={slideCount}
|
||||
label={t("feed.activityOf", { n: Math.min(index + 1, total), total })}
|
||||
/>
|
||||
</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)}
|
||||
<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={onExit}
|
||||
aria-label={t("feed.back")}
|
||||
title={t("feed.back")}
|
||||
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)]">
|
||||
{title}
|
||||
</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 className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{Math.min(index + 1, total)}/{total}
|
||||
|
||||
{timerSeconds ? (
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -238,54 +238,61 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
const progressMax = slideCount;
|
||||
|
||||
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 ---------------------------------------------------- */}
|
||||
{/* 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">
|
||||
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={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)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProgressBar
|
||||
value={progressValue}
|
||||
max={progressMax}
|
||||
label={t("feed.activityOf", { n: progressValue, total: progressMax })}
|
||||
/>
|
||||
</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}
|
||||
<div className="study-chrome mx-auto flex max-w-xl items-center gap-2.5 rounded-full px-2.5 py-2">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={t("feed.back")}
|
||||
title={t("feed.back")}
|
||||
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" />
|
||||
</Link>
|
||||
|
||||
<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)]">
|
||||
{currentDeck.title}
|
||||
</span>
|
||||
<span
|
||||
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)]"
|
||||
title={t("feed.xp", { n: xp })}
|
||||
aria-label={t("feed.xp", { n: xp })}
|
||||
>
|
||||
<BoltIcon className="size-3.5" />
|
||||
{xp}
|
||||
<span className="shrink-0 text-[11.5px] font-bold tabular-nums text-[var(--text-muted)]">
|
||||
{retryMode ? t("feed.retry") : null}
|
||||
{Math.min(index + 1, total)}/{total}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
className="mt-1.5"
|
||||
value={progressValue}
|
||||
max={progressMax}
|
||||
label={t("feed.activityOf", { n: progressValue, total: progressMax })}
|
||||
/>
|
||||
</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>
|
||||
<span className="shrink-0">
|
||||
{retryMode ? t("feed.retry") : ""}
|
||||
{Math.min(index + 1, total)}/{total}
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -681,8 +681,18 @@ export function ChatScreen() {
|
|||
online={online}
|
||||
scopeLabel={scopeText}
|
||||
modelLabel={activeModel || t("chat.modelTitle")}
|
||||
onOpenScope={() => setScopeOpen(true)}
|
||||
onOpenModel={() => setModelOpen(true)}
|
||||
scopeOpen={scopeOpen}
|
||||
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)}
|
||||
attachBusy={attachBusy}
|
||||
attachments={attachments}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export function Composer({
|
|||
online,
|
||||
scopeLabel,
|
||||
modelLabel,
|
||||
scopeOpen,
|
||||
modelOpen,
|
||||
onOpenScope,
|
||||
onOpenModel,
|
||||
onAttachFile,
|
||||
|
|
@ -41,6 +43,8 @@ export function Composer({
|
|||
online: boolean;
|
||||
scopeLabel: string;
|
||||
modelLabel: string;
|
||||
scopeOpen: boolean;
|
||||
modelOpen: boolean;
|
||||
onOpenScope: () => void;
|
||||
onOpenModel: () => void;
|
||||
onAttachFile: (file: File) => void;
|
||||
|
|
@ -156,16 +160,22 @@ export function Composer({
|
|||
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
data-picker-trigger
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={scopeOpen}
|
||||
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" />
|
||||
<span className="max-w-[170px] truncate">{scopeLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-picker-trigger
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={modelOpen}
|
||||
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" />
|
||||
<span className="max-w-[170px] truncate">{modelLabel}</span>
|
||||
|
|
@ -181,3 +191,16 @@ export function Composer({
|
|||
</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)]",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
|
||||
|
|
@ -34,6 +34,7 @@ export function ContextPicker({
|
|||
const { t, plural } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -44,6 +45,21 @@ export function ContextPicker({
|
|||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [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 choose = (next: ChatScope) => {
|
||||
|
|
@ -207,6 +223,7 @@ export function ContextPicker({
|
|||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
ref={panel}
|
||||
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon } from "@/components/icons";
|
||||
|
|
@ -31,6 +31,7 @@ export function ModelPicker({
|
|||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -41,6 +42,21 @@ export function ModelPicker({
|
|||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [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) =>
|
||||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||
|
||||
|
|
@ -123,6 +139,7 @@ export function ModelPicker({
|
|||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
ref={panel}
|
||||
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function HomeworkCheckResult({
|
|||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("homework.check.verdict")}
|
||||
</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>
|
||||
|
||||
{record.correct.length > 0 ? (
|
||||
|
|
@ -66,10 +66,10 @@ export function HomeworkCheckResult({
|
|||
<ul className="mt-2 space-y-3">
|
||||
{record.wrong.map((entry, index) => (
|
||||
<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>{entry.why}</p>
|
||||
<p className="whitespace-pre-wrap break-words font-semibold">{entry.issue}</p>
|
||||
<p className="whitespace-pre-wrap break-words">{entry.why}</p>
|
||||
{entry.fix ? (
|
||||
<p>
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
<span className="font-bold">{t("homework.check.fix")}: </span>
|
||||
{entry.fix}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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 { XIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
import { AskButton } from "@/components/chat/AskButton";
|
||||
|
|
@ -138,32 +139,37 @@ export function HomeworkGuide({
|
|||
};
|
||||
|
||||
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 ------------------------------------------------------- */}
|
||||
{/* 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 max-w-xl space-y-1.5 rounded-full px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={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)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProgressBar
|
||||
value={progressValue}
|
||||
max={slideCount}
|
||||
label={t("homework.guide.stepOf", { n: progressValue, total: slideCount })}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||
<span className="min-w-0 truncate">{record.title}</span>
|
||||
<span className="shrink-0">
|
||||
{onSolution ? t("homework.guide.solution") : t("homework.guide.stepOf", { n: index + 1, total: steps.length })}
|
||||
</span>
|
||||
<ProgressBar
|
||||
className="mt-1.5"
|
||||
value={progressValue}
|
||||
max={slideCount}
|
||||
label={t("homework.guide.stepOf", { n: progressValue, total: slideCount })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -189,7 +195,10 @@ export function HomeworkGuide({
|
|||
|
||||
<PromptCard>
|
||||
<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}
|
||||
</p>
|
||||
<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)]"
|
||||
>
|
||||
<span className="font-bold">{t("homework.guide.hintN", { n: hintIndex + 1 })}: </span>
|
||||
{hint}
|
||||
<span className="whitespace-pre-wrap break-words">{hint}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
{revealedCount(stepIndex) < step.hints.length ? (
|
||||
|
|
@ -261,6 +270,7 @@ export function HomeworkGuide({
|
|||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
)}
|
||||
<AiDisclaimer />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
|
@ -282,6 +292,7 @@ export function HomeworkGuide({
|
|||
</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
|
||||
|
|
|
|||
|
|
@ -256,7 +256,9 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue