From 7da308fa1e16867c9df307322c94501cd20e578a Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Wed, 14 Oct 2026 09:19:54 +0000 Subject: [PATCH] Aero button glass + homework report control; reduced-motion sheet glitch Buttons: the recipe's thin veils let btn-secondary/btn-ghost/glass-pill blend into the grass and sky - they now carry a stronger pale-glass veil (the Ask-pill look) over the blurred scene, ghosts plus a rim and full-contrast text. Homework make/check results gained the same red "!" report control as the feeds and the guide (a synthetic short-answer activity maps into the report flow; pinned in phase3). Reduced motion: the manual toggle only fed framer's MotionConfig, which strips transform animations - while components branched on the OS-only useReducedMotion - so translucent sheets still spring-faded their opacity in over the page (the "ugly visual glitch with the settings panel"). All 16 components now read useAppReducedMotion() (OS or toggle), and the settings sheet/scrim and the library's phone sheet go instant. Verified frame by frame: opacity 1.00 from the first frame. Contrast 0 findings, axe clean; mobile 126/126, chat 102/102, providers 20/20, wizard 95/95, phase3 31/31, bubbles 34/34, i18n 13/13, native 33/33. Version 0.5.4, SW -> freezmo-v76. --- README.md | 18 ++++++++++ app/globals.css | 38 +++++++++++++++++++++ components/ApiKeyGate.tsx | 3 +- components/InstallSheet.tsx | 3 +- components/LibraryScreen.tsx | 6 ++-- components/LoadingState.tsx | 3 +- components/RouteTransition.tsx | 3 +- components/SegmentedPill.tsx | 3 +- components/SettingsDialog.tsx | 8 +++-- components/TabBar.tsx | 3 +- components/UploadScreen.tsx | 3 +- components/WizardActionBar.tsx | 3 +- components/activities/Flashcard.tsx | 3 +- components/activities/shared.tsx | 3 +- components/chat/ChatScreen.tsx | 2 +- components/chat/ContextPicker.tsx | 3 +- components/chat/HistoryPanel.tsx | 3 +- components/chat/ModelPicker.tsx | 3 +- components/homework/HomeworkCheckResult.tsx | 11 ++++++ components/homework/HomeworkInput.tsx | 3 +- components/homework/HomeworkResult.tsx | 14 ++++++++ lib/motion-pref.ts | 13 +++++++ lib/question-reports.ts | 17 +++++++++ package.json | 2 +- public/sw.js | 2 +- tests/phase3-test.mjs | 4 +++ 26 files changed, 155 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 62eb62a..c5dfeb6 100644 --- a/README.md +++ b/README.md @@ -1423,6 +1423,24 @@ Things I chose while building, with the reasoning: the chrome's edges) — a flat pane floating 40px inside the scene read as "cut off next to the sidebar". +86. **Reduced motion no longer ghosts the sheets.** The in-app toggle fed framer through + `MotionConfig reducedMotion="always"`, which only strips *transform* animations — while every + component branched on the OS-only `useReducedMotion`. So a translucent sheet still spring-faded + its opacity in over the page, showing the page through it for ~300 ms ("ugly visual glitch with + the settings panel when reduced motion is on"). All 16 components now read + `useAppReducedMotion()` (the OS preference *or* the toggle), and the settings sheet + scrim and + the library's phone sheet go instant under reduced motion. Verified frame by frame: opacity + 1.00 from the very first frame. + +85. **Aero's buttons read as buttons, and homework results can be reported.** Two reports from one + screenshot. The recipe's thin veils let secondary/ghost buttons blend into the grass and sky + ("why are there buttons which dont have any background"): `btn-secondary`, `btn-ghost` and + `glass-pill` now carry a stronger pale-glass veil (the "Ask pill" look) over the blurred scene, + with ghosts also getting a rim and full-contrast text. And the homework make/check results had + no report control — both now carry the same red "!" as the study feeds and the guide, mapping + the result into the existing report flow through a synthetic short-answer activity (the report + keeps only the flattened question/type/id), pinned in phase3's result-flow checks. + 84. **"Available" means proven, not just listed.** With a cloud Ollama key the picker marked every model from /api/tags as *available* — but a free plan can list models it cannot run, so the badge promised more than the account could keep. A tag listing now proves availability only on diff --git a/app/globals.css b/app/globals.css index 421124f..91659cb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -621,6 +621,7 @@ .aero .glass-chip, .aero .glass-pill, .aero .btn-secondary, + .aero .btn-ghost, .aero .chat-header, .aero .chat-empty, .aero .segmented { @@ -651,6 +652,43 @@ } } +/* Buttons need more presence than the cards: a stronger veil so a button reads + as pale glass over grass, sky or water — the "Ask pill" look — instead of + blending into the blurred scene. */ +.aero .btn-secondary, +.aero .glass-pill { + background-image: + linear-gradient( + 180deg, + rgb(255 255 255 / 0.52) 0%, + rgb(255 255 255 / 0.36) 46%, + rgb(255 255 255 / 0.32) 53%, + rgb(255 255 255 / 0.44) 100% + ), + url("/aero/background-blur.webp?v=3"); + background-size: 100% 100%, cover; + background-position: 0 0, center 42%; + background-attachment: scroll, fixed; + background-repeat: no-repeat, no-repeat; +} +.aero .btn-ghost { + background-image: + linear-gradient( + 180deg, + rgb(255 255 255 / 0.52) 0%, + rgb(255 255 255 / 0.36) 46%, + rgb(255 255 255 / 0.32) 53%, + rgb(255 255 255 / 0.44) 100% + ), + url("/aero/background-blur.webp?v=3"); + background-size: 100% 100%, cover; + background-position: 0 0, center 42%; + background-attachment: scroll, fixed; + background-repeat: no-repeat, no-repeat; + border: 1px solid rgb(255 255 255 / 0.6); + color: var(--text); +} + /* Chat header: the same glass as the cards, a touch more fill — its title and scope line sit directly over the sky. */ .aero .chat-header { diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx index 7dfdb89..8c8f6bd 100644 --- a/components/ApiKeyGate.tsx +++ b/components/ApiKeyGate.tsx @@ -1,6 +1,7 @@ "use client"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useTheme } from "@/lib/theme"; diff --git a/components/InstallSheet.tsx b/components/InstallSheet.tsx index f00d0e0..b4a9496 100644 --- a/components/InstallSheet.tsx +++ b/components/InstallSheet.tsx @@ -1,6 +1,7 @@ "use client"; -import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion"; +import { AnimatePresence, motion, useDragControls } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useCallback, useEffect, useState } from "react"; import { diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index bbba96a..ddcf17f 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { DeckCard } from "@/components/DeckCard"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { FolderBar } from "@/components/FolderBar"; import { HomeworkCard } from "@/components/HomeworkCard"; import { ChatIcon, EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons"; @@ -32,6 +33,7 @@ import { cn } from "@/lib/utils"; * so the two never mix in a single list. */ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | "homework" }) { + const reduceMotion = useReducedMotion(); const tab = initialTab; const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot); const homeworkRecords = useSyncExternalStore( @@ -576,10 +578,10 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | role="dialog" aria-modal="true" aria-label={t("library.moreTitle")} - initial={{ y: 300 }} + initial={reduceMotion ? false : { y: 300 }} animate={{ y: 0 }} exit={{ y: 300 }} - transition={springSheet} + transition={reduceMotion ? { duration: 0 } : springSheet} className="glass-panel glass-sheet relative z-10 rounded-t-3xl px-4 pb-safe pt-3" >
diff --git a/components/LoadingState.tsx b/components/LoadingState.tsx index b08a9f8..c5d6c74 100644 --- a/components/LoadingState.tsx +++ b/components/LoadingState.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { Button, ProgressBar } from "@/components/ui"; import { useI18n } from "@/lib/i18n"; diff --git a/components/RouteTransition.tsx b/components/RouteTransition.tsx index 2d0244f..b8a3209 100644 --- a/components/RouteTransition.tsx +++ b/components/RouteTransition.tsx @@ -1,6 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { usePathname } from "next/navigation"; import { screenVariants, tabVariants } from "@/lib/motion"; diff --git a/components/SegmentedPill.tsx b/components/SegmentedPill.tsx index 712fcc4..e070ac1 100644 --- a/components/SegmentedPill.tsx +++ b/components/SegmentedPill.tsx @@ -1,6 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { springSnappy } from "@/lib/motion"; import { cn } from "@/lib/utils"; diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 414fe34..9ca04d0 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion"; +import { AnimatePresence, motion, useDragControls } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { LOCALES, useI18n, type Locale } from "@/lib/i18n"; @@ -184,9 +185,10 @@ export function SettingsDialog({ {open ? ( event.stopPropagation()} className="glass-panel glass-sheet relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]" > diff --git a/components/TabBar.tsx b/components/TabBar.tsx index 0d9c502..4d9770f 100644 --- a/components/TabBar.tsx +++ b/components/TabBar.tsx @@ -1,6 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import Link from "next/link"; import { usePathname } from "next/navigation"; diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index c395589..5d5ac66 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -1,6 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; diff --git a/components/WizardActionBar.tsx b/components/WizardActionBar.tsx index 3b21677..901ff8d 100644 --- a/components/WizardActionBar.tsx +++ b/components/WizardActionBar.tsx @@ -1,6 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; diff --git a/components/activities/Flashcard.tsx b/components/activities/Flashcard.tsx index 1e7421c..637e097 100644 --- a/components/activities/Flashcard.tsx +++ b/components/activities/Flashcard.tsx @@ -1,6 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useState } from "react"; import { useI18n } from "@/lib/i18n"; diff --git a/components/activities/shared.tsx b/components/activities/shared.tsx index 8dad7c0..2cfa598 100644 --- a/components/activities/shared.tsx +++ b/components/activities/shared.tsx @@ -1,6 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { createContext, useContext } from "react"; import { useI18n } from "@/lib/i18n"; diff --git a/components/chat/ChatScreen.tsx b/components/chat/ChatScreen.tsx index 8feaec1..e606402 100644 --- a/components/chat/ChatScreen.tsx +++ b/components/chat/ChatScreen.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useReducedMotion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import Link from "next/link"; import { Composer, type PendingAttachment } from "./Composer"; diff --git a/components/chat/ContextPicker.tsx b/components/chat/ContextPicker.tsx index fe43055..9ead5be 100644 --- a/components/chat/ContextPicker.tsx +++ b/components/chat/ContextPicker.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useRef } from "react"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons"; import { useI18n } from "@/lib/i18n"; diff --git a/components/chat/HistoryPanel.tsx b/components/chat/HistoryPanel.tsx index 2c5c273..b134806 100644 --- a/components/chat/HistoryPanel.tsx +++ b/components/chat/HistoryPanel.tsx @@ -1,6 +1,7 @@ "use client"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useEffect, useState } from "react"; import { CheckCircleIcon, PencilIcon, PlusIcon, XIcon } from "@/components/icons"; diff --git a/components/chat/ModelPicker.tsx b/components/chat/ModelPicker.tsx index b6ceb70..496f5a5 100644 --- a/components/chat/ModelPicker.tsx +++ b/components/chat/ModelPicker.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { CheckCircleIcon } from "@/components/icons"; import { useI18n } from "@/lib/i18n"; diff --git a/components/homework/HomeworkCheckResult.tsx b/components/homework/HomeworkCheckResult.tsx index f411170..fc030a7 100644 --- a/components/homework/HomeworkCheckResult.tsx +++ b/components/homework/HomeworkCheckResult.tsx @@ -2,7 +2,9 @@ import { useState } from "react"; +import { ReportQuestionButton } from "@/components/ReportQuestionButton"; import { Button } from "@/components/ui"; +import { homeworkReportActivity } from "@/lib/question-reports"; import { AskButton } from "@/components/chat/AskButton"; import { useI18n } from "@/lib/i18n"; import type { HomeworkCheckRecord } from "@/lib/schemas"; @@ -130,6 +132,15 @@ export function HomeworkCheckResult({

{t("homework.toDeckNote")}

+ +
+ +
); } diff --git a/components/homework/HomeworkInput.tsx b/components/homework/HomeworkInput.tsx index 08b0905..e725bf3 100644 --- a/components/homework/HomeworkInput.tsx +++ b/components/homework/HomeworkInput.tsx @@ -1,6 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { motion } from "framer-motion"; +import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; diff --git a/components/homework/HomeworkResult.tsx b/components/homework/HomeworkResult.tsx index 6ead81c..b150f62 100644 --- a/components/homework/HomeworkResult.tsx +++ b/components/homework/HomeworkResult.tsx @@ -8,6 +8,9 @@ import { useI18n } from "@/lib/i18n"; import type { HomeworkMakeRecord } from "@/lib/schemas"; import { shareHomework } from "@/lib/share"; +import { ReportQuestionButton } from "@/components/ReportQuestionButton"; +import { homeworkReportActivity } from "@/lib/question-reports"; + import { CopyButton } from "./CopyButton"; import { FollowUpBar } from "./FollowUpBar"; @@ -78,6 +81,17 @@ export function HomeworkResult({

{t("homework.toDeckNote")}

+ + {/* Same report control as the study feeds and the guide, under the + result (the homework record is the "deck" here). */} +
+ +
); } diff --git a/lib/motion-pref.ts b/lib/motion-pref.ts index 49b0bac..11d307d 100644 --- a/lib/motion-pref.ts +++ b/lib/motion-pref.ts @@ -1,6 +1,7 @@ "use client"; import { useSyncExternalStore } from "react"; +import { useReducedMotion } from "framer-motion"; /** * The Settings "reduce motion" toggle (default off). It mirrors the OS @@ -49,3 +50,15 @@ export function useReducedMotionPref() { ); return { reduced, setReduced: applyReducedMotion }; } + +/** + * The OS preference *or* the in-app toggle. MotionConfig only strips transform + * animations, so components that branch on `useReducedMotion` (sheets fading + * from transparent, etc.) must read this one — otherwise a manual "reduce + * motion" still faded a translucent sheet in, ghosting the page through it. + */ +export function useAppReducedMotion(): boolean { + const system = useReducedMotion(); + const { reduced } = useReducedMotionPref(); + return Boolean(system) || reduced; +} diff --git a/lib/question-reports.ts b/lib/question-reports.ts index 2cd3164..6735c02 100644 --- a/lib/question-reports.ts +++ b/lib/question-reports.ts @@ -132,6 +132,23 @@ export function withoutReported(deckId: string, activities: Activity[]): Activit } /** Adds (or refreshes) a report and sends a copy to the server, best effort. */ +/** + * A pseudo-activity so a homework result can use the same report flow as a + * study card (the report only keeps the flattened question/type/id). + */ +export function homeworkReportActivity(input: { id: string; title: string; answer: string }): Activity { + const body = input.answer.trim().slice(0, 1200) || input.title; + return { + id: input.id, + xp: 0, + type: "short_answer", + question: input.title.slice(0, 600) || "Homework result", + referenceAnswer: body, + keyPoints: [body.slice(0, 200) || "Result"], + explanation: input.title.slice(0, 600) || "Homework result", + }; +} + export function reportQuestion(input: { /** A deck id, or the homework record's id for a homework exercise. */ deckId: string; diff --git a/package.json b/package.json index f4c1439..2af0b03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.5.2", + "version": "0.5.4", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index f1023fe..df32e11 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-v75"; +const CACHE_VERSION = "freezmo-v76"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/phase3-test.mjs b/tests/phase3-test.mjs index cb106ce..27cd4a6 100644 --- a/tests/phase3-test.mjs +++ b/tests/phase3-test.mjs @@ -191,6 +191,10 @@ try { check("phone: shorter follow-up replaces the text", await phone.page.getByText("SHORTER: x = 2").isVisible()); check("phone: original can be restored", await phone.page.getByRole("button", { name: "Original" }).isVisible()); + check( + "phone: the result carries the report control", + await phone.page.getByRole("button", { name: "Report exercise" }).isVisible(), + ); await phone.page.getByRole("button", { name: "Turn into a study deck" }).click(); await phone.page.waitForTimeout(1500); check("phone: landed on New deck", phone.page.url().includes("/create"));