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.
This commit is contained in:
parent
cb8aeb6100
commit
7da308fa1e
26 changed files with 155 additions and 22 deletions
18
README.md
18
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<div className="sheet-grabber" aria-hidden />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md sm:items-center sm:p-6"
|
||||
initial={{ opacity: 0 }}
|
||||
initial={reduceMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : { duration: 0.18 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
|
|
@ -205,7 +207,7 @@ export function SettingsDialog({
|
|||
initial={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 48, opacity: 0 }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0, opacity: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 32, opacity: 0 }}
|
||||
transition={phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
|
||||
transition={reduceMotion ? { duration: 0 } : phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
|
||||
onClick={(event) => 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]"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<ReportQuestionButton
|
||||
deckId={record.id}
|
||||
deckTitle={record.title}
|
||||
activity={homeworkReportActivity({ id: record.id, title: record.title, answer: corrected })}
|
||||
labelKey="homework.report"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-[var(--text-subtle)]">{t("homework.toDeckNote")}</p>
|
||||
|
||||
{/* Same report control as the study feeds and the guide, under the
|
||||
result (the homework record is the "deck" here). */}
|
||||
<div className="flex justify-end">
|
||||
<ReportQuestionButton
|
||||
deckId={record.id}
|
||||
deckTitle={record.title}
|
||||
activity={homeworkReportActivity({ id: record.id, title: record.title, answer })}
|
||||
labelKey="homework.report"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.4",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue