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"));