diff --git a/README.md b/README.md index 5d37da5..2095f3e 100644 --- a/README.md +++ b/README.md @@ -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. 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. - `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 `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 @@ -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 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 `

` — 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 `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 diff --git a/app/globals.css b/app/globals.css index 0ef8bf8..c6d40eb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); } -/* 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 { --ice-base-top: #f4f9ff; --ice-base-bottom: #e6f1fb; - --ice-blob-opacity: 0.18; + --ice-blob-opacity: 0.045; --ice-orb-opacity: 0.34; --ice-grain-opacity: 0.05; } .dark { --ice-base-top: #070b16; --ice-base-bottom: #0c1426; - --ice-blob-opacity: 0.4; + --ice-blob-opacity: 0.1; --ice-orb-opacity: 0.5; --ice-grain-opacity: 0.045; } @@ -217,6 +220,14 @@ body { 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 fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index 957092b..ef7244b 100644 --- a/components/AppHeader.tsx +++ b/components/AppHeader.tsx @@ -43,7 +43,7 @@ export function AppHeader({ className, compact = false }: { className?: string; // No negative margins here: the header is a direct child of

, // 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, )} > diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx index 279dba6..78b5cf8 100644 --- a/components/SessionFeed.tsx +++ b/components/SessionFeed.tsx @@ -144,43 +144,49 @@ export function SessionFeed({ const score = attemptedTotal > 0 ? Math.round((correct / attemptedTotal) * 100) : 0; return ( -
+
+ {/* Same pill as the study feed: round close button, title + position + above the bar, timer at the other end (nothing crowds the ends). */}
-
-
- -
- -
- {timerSeconds ? ( - - {formatClock(remaining)} +
+ + +
+
+ + {title} - ) : null} + + {Math.min(index + 1, total)}/{total} + +
+
-
- {title} - - {Math.min(index + 1, total)}/{total} + + {timerSeconds ? ( + + {formatClock(remaining)} -
+ ) : null}
diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx index a5ac405..b86aa45 100644 --- a/components/StudyFeed.tsx +++ b/components/StudyFeed.tsx @@ -238,54 +238,61 @@ export function StudyFeed({ deck }: { deck: Deck }) { const progressMax = slideCount; return ( -
+
{/* 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. */}
-
-
- - - -
- -
-
- = 3 - ? "tint-peach" - : "bg-[var(--surface-sunken)] text-[var(--text-muted)]", - )} - title={t("feed.streak", { n: streak })} - aria-label={t("feed.streak", { n: streak })} - > - - {streak} +
+ + + + +
+
+ + {currentDeck.title} - - - {xp} + + {retryMode ? t("feed.retry") : null} + {Math.min(index + 1, total)}/{total}
+
-
- {currentDeck.title} - - {retryMode ? t("feed.retry") : ""} - {Math.min(index + 1, total)}/{total} + +
+ = 3 + ? "tint-peach" + : "bg-[var(--surface-sunken)] text-[var(--text-muted)]", + )} + title={t("feed.streak", { n: streak })} + aria-label={t("feed.streak", { n: streak })} + > + + {streak} + + + + {xp}
diff --git a/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx index f047c99..f37d5cc 100644 --- a/components/chat/ChatScreen.tsx +++ b/components/chat/ChatScreen.tsx @@ -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} diff --git a/components/chat/Composer.tsx b/components/chat/Composer.tsx index ae0da61..1eba70d 100644 --- a/components/chat/Composer.tsx +++ b/components/chat/Composer.tsx @@ -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({
); } + +/** + * 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)]", + ); +} diff --git a/components/chat/ContextPicker.tsx b/components/chat/ContextPicker.tsx index d98e491..1398599 100644 --- a/components/chat/ContextPicker.tsx +++ b/components/chat/ContextPicker.tsx @@ -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(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(); const choose = (next: ChatScope) => { @@ -207,6 +223,7 @@ export function ContextPicker({ {open ? ( (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({ {open ? ( {t("homework.check.verdict")}

-

{record.verdict}

+

{record.verdict}

{record.correct.length > 0 ? ( @@ -66,10 +66,10 @@ export function HomeworkCheckResult({
    {record.wrong.map((entry, index) => (
  • -

    {entry.issue}

    -

    {entry.why}

    +

    {entry.issue}

    +

    {entry.why}

    {entry.fix ? ( -

    +

    {t("homework.check.fix")}: {entry.fix}

    diff --git a/components/homework/HomeworkGuide.tsx b/components/homework/HomeworkGuide.tsx index b3a5d62..a7af976 100644 --- a/components/homework/HomeworkGuide.tsx +++ b/components/homework/HomeworkGuide.tsx @@ -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 ( -
    +
    {/* chrome ------------------------------------------------------- */} + {/* Same pill as the study feed: round close button, title + step above + the bar (nothing crowds the rounded ends). */}
    -
    -
    - -
    - +
    + + +
    +
    + + {record.title} + + + {onSolution ? t("homework.guide.solution") : t("homework.guide.stepOf", { n: index + 1, total: steps.length })} +
    -
    -
    - {record.title} - - {onSolution ? t("homework.guide.solution") : t("homework.guide.stepOf", { n: index + 1, total: steps.length })} - +
    @@ -189,7 +195,10 @@ export function HomeworkGuide({ {step.title} -

    + {/* 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. */} +

    {explained[stepIndex] ?? step.explanation}

    @@ -221,7 +230,7 @@ export function HomeworkGuide({ className="glass-chip rounded-2xl p-3 text-sm leading-relaxed text-[var(--text)]" > {t("homework.guide.hintN", { n: hintIndex + 1 })}: - {hint} + {hint} ))} {revealedCount(stepIndex) < step.hints.length ? ( @@ -261,6 +270,7 @@ export function HomeworkGuide({ → )} +
    ))} @@ -282,6 +292,7 @@ export function HomeworkGuide({

    {t("homework.guide.solutionNote")}

    +
    )} - + {/* 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 : }
    ); } diff --git a/lib/prompts.ts b/lib/prompts.ts index 992f0b4..2e0ceba 100644 --- a/lib/prompts.ts +++ b/lib/prompts.ts @@ -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. - Each step has: * "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. * "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. diff --git a/public/sw.js b/public/sw.js index aa3a537..401431e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * 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 ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/chat-test.mjs b/tests/chat-test.mjs index 984369d..29b6091 100644 --- a/tests/chat-test.mjs +++ b/tests/chat-test.mjs @@ -289,6 +289,36 @@ try { await desktop.page.getByRole("button", { name: "Expand sidebar" }).click(); await desktop.page.waitForTimeout(400); 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(); const phone = await makePage(browser); diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs index 88c234a..808b1a0 100644 --- a/tests/phase2-test.mjs +++ b/tests/phase2-test.mjs @@ -33,6 +33,29 @@ const browser = await launchBrowser(); await seed(page, { decks: [DECK] }); await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" }); 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.waitForTimeout(500); 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.waitForTimeout(1000); 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); // 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.