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

@ -165,6 +165,14 @@ no database, no data leaving your machine except the requests you send to Ollama
correct/wrong feedback, key status
- Micro-interactions: 160–200ms colour/transform transitions, `active:scale-[0.985]`, skeleton
shimmer, hairline hover washes on every row and card
- **Motion (phones)**: one shared language (`lib/motion.ts`) — every button, card, chip, tab and
drawer row scales to 0.96 on press with a spring release; the tab bar, segmented controls and
difficulty/size pickers glide their highlight with a shared `layoutId`; wizard steps slide
forward and backward and page content enters direction-aware; the Settings sheet and drawer
follow the finger and settle on release velocity; library cards fade up in a capped stagger and
animate out on delete; score and XP count up; correct answers pulse, wrong ones shake;
`prefers-reduced-motion` collapses everything to short fades. Only transform and opacity
animate — never blur.
- **iOS-flavoured PWA**: standalone mode, safe-area insets, bottom tab bar, sheet-style dialogs with
a grabber, inset grouped lists, segmented controls, "Add to Home Screen" hint — see
[Install as an app](#install-as-an-app-pwa)
@ -934,6 +942,18 @@ Things I chose while building, with the reasoning:
health cards for a Connection status row plus a collapsed Details list. Decorative orbs were
pushed further off the phone edges so they never glow under CTAs or cards.
37. **The animation pass is one shared language, and it stays off the desktop.** `lib/motion.ts`
owns the spring presets (snappy / gentle / sheet), durations and variants; components import
them instead of ad-hoc numbers. Press feedback is CSS (a spring-like cubic-bezier on
transform) scoped to `≤639px / pointer: coarse`, so every control responds without
per-component wiring. Sliding highlights use one `layoutId` per control. Route content
animates on entrance only — the App Router unmounts the outgoing page, so exits are left to
in-page `AnimatePresence` (list items, sheets). Only transform and opacity animate (plus the
explicit expander heights); `prefers-reduced-motion` collapses motion to short fades.
LazyMotion was skipped on purpose: the sliding highlights need layout animations (domMax),
the same feature set the current `motion` import already pulls in. Verified with a
4x-CPU-throttled mobile trace (no long tasks, CLS 0) and a reduced-motion run.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision

View file

@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar";
import { UploadScreen } from "@/components/UploadScreen";
@ -29,7 +30,9 @@ export default async function CreatePage({
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16">
<UploadScreen />
<RouteTransition>
<UploadScreen />
</RouteTransition>
</div>
<TabBar />
</main>

View file

@ -708,6 +708,129 @@ body {
.pb-safe { padding-bottom: max(env(safe-area-inset-bottom), 0.75rem) }
.pb-tabbar { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.25rem) }
/* ---------------------------------------------------------------------------
Mobile animation foundation (≤639px / coarse pointers).
Press feedback is CSS so every button, chip, card, tab and row gets the
same instant response without wiring each component. The springy release is
a spring-like cubic-bezier on transform only; reduced motion drops it.
--------------------------------------------------------------------------- */
@media (max-width: 639px), (pointer: coarse) {
.press,
.btn-primary,
.btn-secondary,
.btn-ghost,
.card-hover,
.nav-row,
.folder-pill,
.segmented > button,
.segmented > a,
.tabbar a,
.tabbar button,
summary {
transition:
transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1),
background-color 0.16s ease,
color 0.16s ease,
border-color 0.16s ease,
box-shadow 0.16s ease;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.press:active,
.btn-primary:active,
.btn-secondary:active,
.btn-ghost:active,
.card-hover:active,
.nav-row:active,
.folder-pill:active,
.segmented > button:active,
.segmented > a:active,
.tabbar a:active,
.tabbar button:active {
transform: scale(0.96);
}
/* UI chrome is not selectable; content text stays selectable. */
.btn-primary,
.btn-secondary,
.btn-ghost,
.nav-row,
.folder-pill,
.segmented,
.tabbar,
.study-chrome,
summary,
.press {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
}
@media (prefers-reduced-motion: reduce) {
.press:active,
.btn-primary:active,
.btn-secondary:active,
.btn-ghost:active,
.card-hover:active,
.nav-row:active,
.folder-pill:active,
.segmented > button:active,
.segmented > a:active,
.tabbar a:active,
.tabbar button:active {
transform: none;
}
}
/* Sliding segmented highlight: when a control carries the shared layout
pill, the item itself goes transparent so the pill can glide between
items. (Unlayered, like the rules above, so it wins over them.) */
.segmented > .seg-slide[aria-current="page"],
.segmented > .seg-slide[aria-pressed="true"],
.segmented > .seg-slide[aria-checked="true"] {
background-image: none;
box-shadow: none;
}
.segmented > .seg-slide[aria-current="page"] > .seg-label,
.segmented > .seg-slide[aria-pressed="true"] > .seg-label,
.segmented > .seg-slide[aria-checked="true"] > .seg-label {
color: #06111f;
}
.dark .segmented > .seg-slide > .seg-label {
color: var(--color-frost-300);
}
.dark .segmented > .seg-slide[aria-current="page"] > .seg-label,
.dark .segmented > .seg-slide[aria-pressed="true"] > .seg-label,
.dark .segmented > .seg-slide[aria-checked="true"] > .seg-label {
color: #06111f;
}
/* Shared sliding pill for non-segmented pickers (difficulty, size). */
.slide-pill {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
/* App launch: one quick fade of the shell, no layout shift. */
@keyframes app-in {
from { opacity: 0 }
to { opacity: 1 }
}
.app-shell-in { animation: app-in 0.28s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.app-shell-in { animation: none; }
}
/* Phones: a quick fade when the study feed takes over (the generation
hand-off), so the result doesn't hard-cut in. */
@media (max-width: 639px) {
.fade-in-mobile { animation: app-in 0.28s ease-out both; }
}
@media (max-width: 639px) and (prefers-reduced-motion: reduce) {
.fade-in-mobile { animation: none; }
}
@media (display-mode: standalone) {
.standalone-hide { display: none }
}

View file

@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar";
@ -29,7 +30,9 @@ export default async function HomeworkPage({
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} />
<RouteTransition>
<HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} />
</RouteTransition>
</div>
<TabBar />
</main>

View file

@ -78,7 +78,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<script dangerouslySetInnerHTML={{ __html: installScript }} />
</head>
<body className="min-h-full antialiased">
<body className="min-h-full antialiased app-shell-in">
<IceBackdrop />
{children}
<InstallSheet />

View file

@ -1,6 +1,7 @@
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { LibraryScreen } from "@/components/LibraryScreen";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar";
@ -19,7 +20,9 @@ export default async function HomePage({
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} />
<RouteTransition>
<LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} />
</RouteTransition>
</div>
<TabBar />
</main>

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>

123
lib/motion.ts Normal file
View file

@ -0,0 +1,123 @@
"use client";
/**
* One shared motion language for the mobile animation pass.
*
* Every component imports its presets from here instead of inventing ad-hoc
* springs, so the whole app feels like one native surface:
*
* - `springSnappy` — press releases and small UI changes (180–220ms feel).
* - `springGentle` — screen content, lists, feedback panels (280–350ms).
* - `springSheet` — bottom sheets and the drawer, tuned for dragging.
* - `EASE_OUT` — quick fades that don't need a spring.
*
* Only transform and opacity are ever animated (plus the explicit height
* animation of expanders); blur and backdrop-filter are never touched.
* `prefers-reduced-motion` is handled by the callers (CSS media query for
* press feedback, `useReducedMotion` for JS springs) so motion collapses to
* short fades without changing layout.
*/
import type { Transition, Variants } from "framer-motion";
import { useEffect, useState } from "react";
/** Press feedback and other tiny changes. ~200ms spring, no bounce overshoot. */
export const springSnappy: Transition = { type: "spring", stiffness: 520, damping: 34, mass: 0.7 };
/** Content entering/exiting a screen or list. ~300ms, a whisper of overshoot. */
export const springGentle: Transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.9 };
/** Sheets and the drawer: responsive to release velocity, settles fast. */
export const springSheet: Transition = { type: "spring", stiffness: 380, damping: 36, mass: 1 };
/** Crisp fade for elements that should not move. */
export const fade: Transition = { duration: 0.18, ease: "easeOut" };
/** Plain ease for fades and progress bars. */
export const EASE_OUT = [0.22, 1, 0.36, 1] as const;
/** Durations (seconds) used across the pass. */
export const DURATION = {
press: 0.1,
small: 0.2,
screen: 0.3,
sheet: 0.34,
} as const;
/** A screen entering from the right (drill-in) or left (back). */
export function screenVariants(direction: 1 | -1, reduceMotion: boolean): Variants {
if (reduceMotion) {
return {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.12 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
};
}
return {
initial: { opacity: 0, x: 24 * direction },
animate: { opacity: 1, x: 0, transition: springGentle },
exit: { opacity: 0, x: -16 * direction, transition: { duration: 0.18, ease: "easeIn" } },
};
}
/** Crossfade for same-depth route switches (tab to tab). */
export function tabVariants(reduceMotion: boolean): Variants {
if (reduceMotion) {
return { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } };
}
return {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0, transition: springGentle },
exit: { opacity: 0, y: -6, transition: { duration: 0.16, ease: "easeIn" } },
};
}
/** Wizard step: forward slides in from the right, Back from the left. */
export function stepVariants(direction: 1 | -1, reduceMotion: boolean): Variants {
if (reduceMotion) {
return { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.12 } } };
}
return {
initial: { opacity: 0, x: 18 * direction },
animate: { opacity: 1, x: 0, transition: { duration: 0.24, ease: EASE_OUT } },
};
}
/** Capped stagger for a list's first paint. */
export const listContainer: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.045, delayChildren: 0.02 } },
};
export const listItem: Variants = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0, transition: springGentle },
};
/**
* Counts a number up over ~600ms with an ease-out curve (score, XP). Pass
* `disabled` (desktop, reduced motion) to jump straight to the value.
*/
export function useCountUp(target: number, disabled = false): number {
const [value, setValue] = useState(disabled ? target : 0);
useEffect(() => {
if (disabled) {
setValue(target);
return;
}
let frame = 0;
const start = performance.now();
const duration = 600;
const tick = (now: number) => {
const progress = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setValue(Math.round(target * eased));
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target, disabled]);
return value;
}

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v20";
const CACHE_VERSION = "freezmo-v21";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;