Animation pass: one shared motion language for phones
Mobile only (<=639px / coarse pointers); desktop keeps its behaviour. Foundation - lib/motion.ts owns the spring presets (snappy/gentle/sheet), durations, easings and variants (screens, tabs, wizard steps, list stagger). - Native-feel CSS: press feedback scales every button, card, chip, tab, row and summary to 0.96 with a spring-like cubic-bezier on transform (scoped to phones/coarse pointers), tap-highlight off, touch-action manipulation, chrome text non-selectable, reduced motion drops it. - App shell fades in once on launch; the study feed fades in on phones. Applied - Tab bar: sliding active pill (shared layoutId) + icon bounce. - Study Decks/Homework, language toggle, difficulty and size pickers: the same gliding highlight, gated so desktop keeps the old styling. - Route content enters direction-aware (deeper = from the right, back = from the left, same-depth = crossfade) via RouteTransition on the library/create/homework content columns; library tab switches crossfade; wizard steps slide forward/backward (reduced motion safe). - Library: cards fade up in a capped stagger on first load, glide with layout animations and shrink away on delete (AnimatePresence). - Study feed: correct feedback pulses, wrong shakes, the progress bar settles with a spring curve, score and XP count up on the results. - Settings sheet uses the sheet spring; the drawer now follows the finger and closes on release velocity, like the sheet. - Generation progress: the stage message fades/slides between stages; the icon keeps its gentle wobble; the feed fades in on hand-off. Skipped, on purpose: LazyMotion (the sliding highlights need layout animations = domMax, same features the current import already has); route exit animations (the App Router unmounts the outgoing page — exits are handled by in-page AnimatePresence); replacing the study feed's native scroll-snap with finger-follow drag (would fight the platform scroller); finger-follow height animation for native <details> expanders. Verified: animation suite 8/8 (reduced motion + 4x-CPU-throttled trace: no long tasks, CLS 0.0000), regression 74/74, mobile 52/52, declutter 116/116, i18n 25/25. Lint + build clean. SW freezmo-v21.
This commit is contained in:
parent
6564e88138
commit
374752c59e
22 changed files with 633 additions and 77 deletions
|
|
@ -28,12 +28,14 @@ import {
|
|||
} from "@/lib/image";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { stepVariants } from "@/lib/motion";
|
||||
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
||||
import { upsertDeck } from "@/lib/storage";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn, uid } from "@/lib/utils";
|
||||
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
|
@ -103,6 +105,8 @@ export function UploadScreen() {
|
|||
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "" });
|
||||
/** Phones only: which step of the 3-step flow is showing. */
|
||||
const [step, setStep] = useState(1);
|
||||
/** 1 = moving forward (slides from the right), -1 = Back (from the left). */
|
||||
const [stepDir, setStepDir] = useState<1 | -1>(1);
|
||||
/** Phones only: the “paste text instead” field on step 1. */
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
const isPhone = useIsPhone();
|
||||
|
|
@ -407,17 +411,26 @@ export function UploadScreen() {
|
|||
aria-checked={selected}
|
||||
onClick={() => setDifficulty(option.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
|
||||
selected
|
||||
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
|
||||
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
|
||||
"relative flex flex-col items-center gap-1 overflow-hidden rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
|
||||
selected && isPhone
|
||||
? "border-transparent"
|
||||
: selected
|
||||
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
|
||||
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-frost-600 dark:text-frost-200">
|
||||
{selected && isPhone ? (
|
||||
<SegmentedPill
|
||||
layoutId="difficulty-pill"
|
||||
plain
|
||||
className="rounded-2xl border border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
|
||||
/>
|
||||
) : null}
|
||||
<span aria-hidden className="relative z-10 text-frost-600 dark:text-frost-200">
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span>
|
||||
<span className="hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
|
||||
<span className="relative z-10 text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span>
|
||||
<span className="relative z-10 hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -436,13 +449,16 @@ export function UploadScreen() {
|
|||
aria-pressed={count === option}
|
||||
onClick={() => setCount(option)}
|
||||
className={cn(
|
||||
"min-w-12 rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
|
||||
count === option
|
||||
? "grad-ice border-transparent text-[#06111f]"
|
||||
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||||
"relative min-w-12 overflow-hidden rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
|
||||
count === option && isPhone
|
||||
? "border-transparent"
|
||||
: count === option
|
||||
? "grad-ice border-transparent text-[#06111f]"
|
||||
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
{count === option && isPhone ? <SegmentedPill layoutId="count-pill" className="rounded-xl" /> : null}
|
||||
<span className={cn("relative z-10", count === option && isPhone && "text-[#06111f]")}>{option}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -559,9 +575,9 @@ export function UploadScreen() {
|
|||
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={reduceMotion ? false : { opacity: 0, x: 18 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={stepVariants(stepDir, !!reduceMotion)}
|
||||
className="mt-5"
|
||||
>
|
||||
{step === 1 ? (
|
||||
|
|
@ -732,7 +748,10 @@ export function UploadScreen() {
|
|||
variant="secondary"
|
||||
size="lg"
|
||||
className="touch-target flex-1"
|
||||
onClick={() => setStep((value) => Math.max(1, value - 1))}
|
||||
onClick={() => {
|
||||
setStepDir(-1);
|
||||
setStep((value) => Math.max(1, value - 1));
|
||||
}}
|
||||
disabled={step === 1}
|
||||
>
|
||||
{t("common.back")}
|
||||
|
|
@ -741,7 +760,10 @@ export function UploadScreen() {
|
|||
<Button
|
||||
size="lg"
|
||||
className="touch-target flex-[1.5]"
|
||||
onClick={() => setStep((value) => Math.min(3, value + 1))}
|
||||
onClick={() => {
|
||||
setStepDir(1);
|
||||
setStep((value) => Math.min(3, value + 1));
|
||||
}}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
|
|
@ -1054,6 +1076,7 @@ const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; keys: string[] }> =
|
|||
|
||||
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [tick, setTick] = useState(0);
|
||||
const [tip, setTip] = useState(0);
|
||||
|
||||
|
|
@ -1089,9 +1112,16 @@ function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCa
|
|||
</motion.div>
|
||||
|
||||
<div className="relative w-full max-w-md space-y-3">
|
||||
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
|
||||
<motion.p
|
||||
key={message}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="text-xl font-extrabold text-[var(--text)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</motion.p>
|
||||
<ProgressBar value={activeIndex + 1} max={2} label={t("upload.generationProgress")} />
|
||||
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{STAGE_STEPS.map((step, index) => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue