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
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
|
|
@ -7,13 +8,16 @@ import { DeckCard } from "@/components/DeckCard";
|
|||
import { FolderBar } from "@/components/FolderBar";
|
||||
import { HomeworkCard } from "@/components/HomeworkCard";
|
||||
import { FolderIcon, UploadIcon } from "@/components/icons";
|
||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||
import { Button, EmptyState } from "@/components/ui";
|
||||
import { folderStore, renameFolder } from "@/lib/folders";
|
||||
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
|
|
@ -38,6 +42,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
const selected = useSelectedFolder();
|
||||
const setSelected = folderSelection.set;
|
||||
const { t, plural } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
|
@ -149,11 +154,21 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
{/* Two pools, never mixed: study decks and saved homework. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="segmented min-w-0 flex-1 sm:max-w-sm">
|
||||
<Link href="/" aria-current={tab === "decks" ? "page" : undefined}>
|
||||
{t("tabDecks")}
|
||||
<Link
|
||||
href="/"
|
||||
aria-current={tab === "decks" ? "page" : undefined}
|
||||
className={cn("relative", isPhone && "seg-slide")}
|
||||
>
|
||||
{isPhone && tab === "decks" ? <SegmentedPill layoutId="library-tabs" /> : null}
|
||||
<span className="seg-label relative z-10">{t("tabDecks")}</span>
|
||||
</Link>
|
||||
<Link href="/?tab=homework" aria-current={tab === "homework" ? "page" : undefined}>
|
||||
{t("tabHomework")}
|
||||
<Link
|
||||
href="/?tab=homework"
|
||||
aria-current={tab === "homework" ? "page" : undefined}
|
||||
className={cn("relative", isPhone && "seg-slide")}
|
||||
>
|
||||
{isPhone && tab === "homework" ? <SegmentedPill layoutId="library-tabs" /> : null}
|
||||
<span className="seg-label relative z-10">{t("tabHomework")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -203,6 +218,15 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Phones: switching pools crossfades the content. */}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={isPhone ? tab : "static"}
|
||||
initial={isPhone ? { opacity: 0, y: 8 } : false}
|
||||
animate={isPhone ? { opacity: 1, y: 0 } : undefined}
|
||||
exit={isPhone ? { opacity: 0, y: -6, transition: { duration: 0.16 } } : undefined}
|
||||
transition={springGentle}
|
||||
>
|
||||
{/* decks: folders + card grid, or a decks-only empty state --------- */}
|
||||
{tab === "decks" && isEmpty ? (
|
||||
<EmptyState
|
||||
|
|
@ -321,19 +345,22 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
</div>
|
||||
) : (
|
||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
|
||||
{group.decks.map((deck) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
onMove={(folderId) => {
|
||||
upsertDeck({ ...deck, folderId });
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<AnimatePresence initial={false}>
|
||||
{group.decks.map((deck, index) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
index={index}
|
||||
onMove={(folderId) => {
|
||||
upsertDeck({ ...deck, folderId });
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -358,13 +385,18 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
|
||||
{tab === "homework" && homework.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
|
||||
{homework.map((record) => (
|
||||
<HomeworkCard key={record.id} record={record} onDelete={deleteHomework} />
|
||||
))}
|
||||
<AnimatePresence initial={false}>
|
||||
{homework.map((record, index) => (
|
||||
<HomeworkCard key={record.id} record={record} onDelete={deleteHomework} index={index} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue