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,14 +1,17 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { useState } from "react";
import { ApiError, generateDeck } from "@/lib/client-api";
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
import { createFolder as makeFolder, listFolders } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils";
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
@ -21,8 +24,18 @@ const ADD_OPTIONS = [3, 5, 8];
* One deck in the dashboard grid: title, muted metadata, tags, summary and a
* prominent Study CTA with quiet secondary actions.
*/
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
export function DeckCard({
deck,
onMove,
index = 0,
}: {
deck: Deck;
onMove?: (folderId: string | undefined) => void;
/** Position in the list — used for the capped first-load stagger. */
index?: number;
}) {
const { t, plural } = useI18n();
const isPhone = useIsPhone();
const folders = listFolders();
const [busy, setBusy] = useState<Busy>(null);
const [menuOpen, setMenuOpen] = useState(false);
@ -110,8 +123,23 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
const menuRow =
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-[13px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]";
/* Phones: cards fade up in a short capped stagger, glide when the grid
reflows (folder moves, filtering) and shrink away on delete. */
const cardVariants = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0, transition: { ...springGentle, delay: Math.min(index, 8) * 0.04 } },
exit: { opacity: 0, scale: 0.97, transition: { duration: 0.16 } },
};
return (
<li className="card card-hover group relative flex flex-col p-4 sm:p-5">
<motion.li
layout={isPhone}
variants={isPhone ? cardVariants : undefined}
initial={isPhone ? "hidden" : false}
animate={isPhone ? "show" : undefined}
exit={isPhone ? "exit" : undefined}
className="card card-hover group relative flex flex-col p-4 sm:p-5"
>
{/* Phones: the whole card is tappable too; interactive children sit above it. */}
<Link
href={`/deck/${deck.id}`}
@ -474,7 +502,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
</div>
) : null}
</li>
</motion.li>
);
}

View file

@ -1,24 +1,43 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
import { useState } from "react";
import { BoltIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import type { HomeworkRecord } from "@/lib/schemas";
import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils";
/**
* One saved homework result in the library's Homework tab: the finished
* solution or the step-by-step guide, with open + delete.
*/
export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onDelete: (id: string) => void }) {
export function HomeworkCard({
record,
onDelete,
index = 0,
}: {
record: HomeworkRecord;
onDelete: (id: string) => void;
/** Position in the list — used for the capped first-load stagger. */
index?: number;
}) {
const { t, plural } = useI18n();
const isPhone = useIsPhone();
const [confirmDelete, setConfirmDelete] = useState(false);
const [expanded, setExpanded] = useState(false);
const href = `/homework?open=${record.id}`;
const cardVariants = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0, transition: { ...springGentle, delay: Math.min(index, 8) * 0.04 } },
exit: { opacity: 0, scale: 0.97, transition: { duration: 0.16 } },
};
const preview =
record.mode === "make"
? truncate(record.answer.replace(/\s+/g, " ").trim(), 150)
@ -28,7 +47,14 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
);
return (
<li className="card card-hover group relative flex flex-col p-4 sm:p-5">
<motion.li
layout={isPhone}
variants={isPhone ? cardVariants : undefined}
initial={isPhone ? "hidden" : false}
animate={isPhone ? "show" : undefined}
exit={isPhone ? "exit" : undefined}
className="card card-hover group relative flex flex-col p-4 sm:p-5"
>
{/* Phones: the whole card opens the result too; the delete button sits above it. */}
<Link href={href} aria-hidden="true" tabIndex={-1} className="absolute inset-0 hidden rounded-[1.5rem] max-sm:block lg:block" />
@ -113,6 +139,6 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
</>
)}
</div>
</li>
</motion.li>
);
}

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

View file

@ -4,8 +4,10 @@ import Link from "next/link";
import { motion } from "framer-motion";
import { useI18n } from "@/lib/i18n";
import { useCountUp } from "@/lib/motion";
import type { Activity, Deck } from "@/lib/schemas";
import { downloadDeck } from "@/lib/storage";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
import { Button, Chip } from "@/components/ui";
@ -82,7 +84,11 @@ export function ResultsScreen({
regenerateError: string | null;
}) {
const { t } = useI18n();
const isPhone = useIsPhone();
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
/* Phones: the score and XP count up; desktop jumps straight in. */
const percentShown = useCountUp(percent, !isPhone);
const xpShown = useCountUp(xp, !isPhone);
const perfect = missed.length === 0 && total > 0;
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
@ -93,7 +99,7 @@ export function ResultsScreen({
animate={{ opacity: 1, scale: 1 }}
className="card flex flex-col items-center gap-3 p-6 text-center"
>
<ScoreRing percent={percent} tone={tone} />
<ScoreRing percent={percent} display={percentShown} tone={tone} />
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
{perfect ? t("results.perfect") : percent >= 60 ? t("results.nice") : t("results.start")}
</h1>
@ -109,7 +115,7 @@ export function ResultsScreen({
<div className="mt-1 flex flex-wrap justify-center gap-2">
<Chip tone="accent">
<BoltIcon className="size-3.5" /> {t("results.xpEarned", { n: xp })}
<BoltIcon className="size-3.5" /> {t("results.xpEarned", { n: xpShown })}
</Chip>
<Chip tone="warning">
<FlameIcon className="size-3.5" /> {t("results.bestStreak", { n: bestStreak })}
@ -203,7 +209,7 @@ export function ResultsScreen({
);
}
function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "frost" | "amber" }) {
function ScoreRing({ percent, display, tone }: { percent: number; display: number; tone: "emerald" | "frost" | "amber" }) {
const { t } = useI18n();
const radius = 52;
const circumference = 2 * Math.PI * radius;
@ -232,7 +238,7 @@ function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "fros
/>
</svg>
<div className="absolute flex flex-col items-center">
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{percent}%</span>
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{display}%</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
{t("results.score")}
</span>

View file

@ -0,0 +1,46 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { usePathname } from "next/navigation";
import { screenVariants, tabVariants } from "@/lib/motion";
/**
* Direction-aware screen entrance for the page content column.
*
* Deeper paths (library → deck, library → create/homework) slide in from the
* right; going back slides from the left; same-depth switches crossfade.
* The wrapper only ever wraps the scrolling content column — fixed chrome
* (tab bar, sheets) stays outside it, so a transform can never capture it.
*
* Note: the App Router unmounts the outgoing page immediately, so this is an
* entrance animation (exits animate for in-page elements via AnimatePresence,
* e.g. list items).
*/
/** Module-level so the direction survives the per-route remount. */
let lastPath: string | null = null;
function depth(path: string): number {
if (path === "/") return 0;
return path.split("/").filter(Boolean).length;
}
export function RouteTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const reduceMotion = useReducedMotion();
const previous = lastPath;
lastPath = pathname;
const isTabSwitch = previous !== null && depth(previous) === depth(pathname) && previous !== pathname;
const direction: 1 | -1 =
previous === null || depth(pathname) >= depth(previous) ? 1 : -1;
const variants = isTabSwitch ? tabVariants(!!reduceMotion) : screenVariants(direction, !!reduceMotion);
return (
<motion.div initial="initial" animate="animate" variants={variants}>
{children}
</motion.div>
);
}

View file

@ -0,0 +1,36 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { springSnappy } from "@/lib/motion";
import { cn } from "@/lib/utils";
/**
* The gliding highlight shared by segmented controls and pickers. One
* `layoutId` per control makes the pill slide between its items; under
* reduced motion it simply appears in place.
*/
export function SegmentedPill({
layoutId,
className,
plain,
}: {
layoutId: string;
className?: string;
/** Skip the ice gradient (for pickers that keep their own selected look). */
plain?: boolean;
}) {
const reduceMotion = useReducedMotion();
const base = plain ? "slide-pill" : "slide-pill grad-ice shadow-[0_6px_16px_-8px_rgb(56_189_248/0.7)]";
if (reduceMotion) {
return <span aria-hidden className={cn(base, className)} />;
}
return (
<motion.span
aria-hidden
layoutId={layoutId}
transition={springSnappy}
className={cn(base, className)}
/>
);
}

View file

@ -4,6 +4,7 @@ import { AnimatePresence, motion, useDragControls, useReducedMotion } from "fram
import { useEffect, useRef, useState } from "react";
import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { clearApiKey, saveSettings } from "@/lib/settings";
@ -20,6 +21,7 @@ import {
XCircleIcon,
} from "@/components/icons";
import { Button, Spinner } from "@/components/ui";
import { SegmentedPill } from "@/components/SegmentedPill";
/** “Checked just now”, or the clock time once the result is older. */
function freshnessLabel(checkedAt: number, locale: Locale, t: (key: string, vars?: Record<string, string>) => string) {
@ -162,7 +164,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
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 ? { duration: 0.34, ease: [0.22, 1, 0.36, 1] } : { type: "spring", stiffness: 340, damping: 32 }}
transition={phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
onClick={(event) => event.stopPropagation()}
className="glass-panel relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]"
>
@ -220,8 +222,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
type="button"
aria-pressed={locale === entry.id}
onClick={() => setLocale(entry.id)}
className={cn("relative", phone && "seg-slide")}
>
{entry.label}
{phone && locale === entry.id ? <SegmentedPill layoutId="language-pill" /> : null}
<span className="seg-label relative z-10">{entry.label}</span>
</button>
))}
</div>

View file

@ -18,6 +18,7 @@ import {
import { useTheme } from "@/components/ThemeToggle";
import { createFolder, folderStore } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
import { UNORGANIZED_ID } from "@/lib/schemas";
import { deckStore } from "@/lib/storage";
@ -416,7 +417,14 @@ export function MobileSidebar() {
initial={{ x: -280 }}
animate={{ x: 0 }}
exit={{ x: -280 }}
transition={{ type: "spring", stiffness: 380, damping: 36 }}
transition={springSheet}
drag="x"
dragConstraints={{ left: -280, right: 0 }}
dragElastic={{ left: 0.45, right: 0 }}
dragMomentum={false}
onDragEnd={(_, gesture) => {
if (gesture.offset.x < -80 || gesture.velocity.x < -500) sidebarOpen.set(false);
}}
className="sidebar-surface relative z-10 flex h-full w-[272px] flex-col rounded-l-none rounded-r-[1.5rem] px-2.5 py-3 pb-safe"
>
<NavContent onNavigate={() => sidebarOpen.set(false)} />

View file

@ -209,7 +209,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const progressMax = slideCount;
return (
<div className="frost-bg fixed inset-0 flex flex-col">
<div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile">
{/* Chrome ---------------------------------------------------- */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">

View file

@ -1,10 +1,12 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { springSnappy } from "@/lib/motion";
import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
@ -12,37 +14,82 @@ import { cn } from "@/lib/utils";
* iOS-style bottom tab bar. Rendered on the library and create screens on
* phones; hidden on desktop (the header carries the same actions there) and on
* the full-screen study feed.
*
* The active item sits on a sliding pill (shared `layoutId`), and its icon
* gives a small bounce when you land on it.
*/
export function TabBar() {
const pathname = usePathname();
const { t } = useI18n();
const reduceMotion = useReducedMotion();
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
const item =
"relative flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
const active = "text-[var(--text)]";
const idle = "text-[var(--text-subtle)]";
/** The pill glides between items; hidden under reduced motion. */
const pill = (isActive: boolean) =>
isActive && !reduceMotion ? (
<motion.span
aria-hidden
layoutId="tabbar-active"
transition={springSnappy}
className="absolute inset-x-2 inset-y-0.5 rounded-2xl bg-[var(--nav-active-bg)] shadow-[inset_0_0_0_1px_var(--nav-active-border)]"
/>
) : null;
/** Small icon bounce on the item you just selected. */
const icon = (children: React.ReactNode, isActive: boolean, key: string) => (
<motion.span
key={`${key}-${isActive ? pathname : "idle"}`}
animate={isActive && !reduceMotion ? { scale: [1, 1.16, 1] } : { scale: 1 }}
transition={springSnappy}
className="relative z-10 grid place-items-center"
>
{children}
</motion.span>
);
const label = (text: string, isActive: boolean) => (
<span className={cn("relative z-10", isActive ? active : idle)}>{text}</span>
);
const libraryActive = pathname === "/";
const createActive = pathname === "/create";
return (
<nav
aria-label={t("tabbar.main")}
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
>
<Link href="/" aria-current={pathname === "/" ? "page" : undefined} className={cn(item, pathname === "/" ? active : idle)}>
<HomeIcon className="size-6" />
{t("common.library")}
<Link
href="/"
aria-current={libraryActive ? "page" : undefined}
className={cn(item, libraryActive ? active : idle)}
>
{pill(libraryActive)}
{icon(<HomeIcon className="size-6" />, libraryActive, "library")}
{label(t("common.library"), libraryActive)}
</Link>
<Link
href="/create"
aria-current={pathname === "/create" ? "page" : undefined}
className={cn(item, pathname === "/create" ? active : idle)}
aria-current={createActive ? "page" : undefined}
className={cn(item, createActive ? active : idle)}
>
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>
{t("common.newDeck")}
{pill(createActive)}
{icon(
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>,
createActive,
"create",
)}
{label(t("common.newDeck"), createActive)}
</Link>
<button type="button" onClick={openSettings} className={cn(item, idle)}>
<GearIcon className="size-6" />
{t("common.settings")}
{icon(<GearIcon className="size-6" />, false, "settings")}
{label(t("common.settings"), false)}
</button>
</nav>
);

View file

@ -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) => (

View file

@ -1,9 +1,10 @@
"use client";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
@ -152,6 +153,8 @@ export function FeedbackPanel({
children?: React.ReactNode;
}) {
const { t } = useI18n();
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: 18 }}
@ -171,6 +174,8 @@ export function FeedbackPanel({
className={cn(
"flex items-center gap-2 text-base font-extrabold",
correct ? "text-emerald-900 dark:text-emerald-200" : "text-rose-900 dark:text-rose-200",
/* Phones: a soft pulse on correct, a quick shake on wrong. */
isPhone && !reduceMotion && (correct ? "animate-pop" : "animate-wiggle"),
)}
>
<span aria-hidden>{correct ? "✅" : "💧"}</span>

View file

@ -13,6 +13,7 @@ import {
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image";
import { useI18n } from "@/lib/i18n";
import { stepVariants } from "@/lib/motion";
import type { useNotesIntake } from "@/lib/use-notes-intake";
import { useIsPhone } from "@/lib/use-phone";
import { openSettings } from "@/lib/ui-events";
@ -68,6 +69,7 @@ export function HomeworkInput({
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState(1);
const [stepDir, setStepDir] = useState<1 | -1>(1);
const [pasteOpen, setPasteOpen] = useState(false);
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
@ -237,9 +239,9 @@ export function HomeworkInput({
<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 ? (
@ -396,13 +398,24 @@ export function HomeworkInput({
variant="secondary"
size="lg"
className="touch-target flex-1"
onClick={() => setStep(1)}
onClick={() => {
setStepDir(-1);
setStep(1);
}}
disabled={step === 1}
>
{t("common.back")}
</Button>
{step === 1 ? (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => setStep(2)} disabled={!canSubmit}>
<Button
size="lg"
className="touch-target flex-[1.5]"
onClick={() => {
setStepDir(1);
setStep(2);
}}
disabled={!canSubmit}
>
{t("common.next")}
</Button>
) : null}

View file

@ -121,7 +121,7 @@ export function ProgressBar({
aria-label={label ?? "Progress"}
>
<div
className="grad-ice h-full rounded-full transition-[width] duration-500 ease-out"
className="grad-ice h-full rounded-full transition-[width] duration-[420ms] [transition-timing-function:cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none"
style={{ width: `${percent}%` }}
/>
</div>