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:
Martin Dvořák 2026-10-10 11:01:29 +00:00
commit 374752c59e
22 changed files with 633 additions and 77 deletions

View file

@ -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>
);
}