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 correct/wrong feedback, key status
- Micro-interactions: 160–200ms colour/transform transitions, `active:scale-[0.985]`, skeleton - Micro-interactions: 160–200ms colour/transform transitions, `active:scale-[0.985]`, skeleton
shimmer, hairline hover washes on every row and card 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 - **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 a grabber, inset grouped lists, segmented controls, "Add to Home Screen" hint — see
[Install as an app](#install-as-an-app-pwa) [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 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. 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 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`). 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 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 { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar"; import { TabBar } from "@/components/TabBar";
import { UploadScreen } from "@/components/UploadScreen"; 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"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16"> <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> </div>
<TabBar /> <TabBar />
</main> </main>

View file

@ -708,6 +708,129 @@ body {
.pb-safe { padding-bottom: max(env(safe-area-inset-bottom), 0.75rem) } .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) } .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) { @media (display-mode: standalone) {
.standalone-hide { display: none } .standalone-hide { display: none }
} }

View file

@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { ApiKeyGate } from "@/components/ApiKeyGate"; import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { HomeworkScreen } from "@/components/homework/HomeworkScreen"; import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar"; 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"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <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"> <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> </div>
<TabBar /> <TabBar />
</main> </main>

View file

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

View file

@ -1,6 +1,7 @@
import { ApiKeyGate } from "@/components/ApiKeyGate"; import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { LibraryScreen } from "@/components/LibraryScreen"; import { LibraryScreen } from "@/components/LibraryScreen";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar"; 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"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <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"> <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> </div>
<TabBar /> <TabBar />
</main> </main>

View file

@ -1,14 +1,17 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { motion } from "framer-motion";
import { useState } from "react"; import { useState } from "react";
import { ApiError, generateDeck } from "@/lib/client-api"; import { ApiError, generateDeck } from "@/lib/client-api";
import { activityTypes, materialiseActivities } from "@/lib/deck-ops"; import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
import { createFolder as makeFolder, listFolders } from "@/lib/folders"; import { createFolder as makeFolder, listFolders } from "@/lib/folders";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas"; import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage"; import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils"; import { cn, formatDate, truncate } from "@/lib/utils";
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons"; import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui"; 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 * One deck in the dashboard grid: title, muted metadata, tags, summary and a
* prominent Study CTA with quiet secondary actions. * 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 { t, plural } = useI18n();
const isPhone = useIsPhone();
const folders = listFolders(); const folders = listFolders();
const [busy, setBusy] = useState<Busy>(null); const [busy, setBusy] = useState<Busy>(null);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -110,8 +123,23 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
const menuRow = 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)]"; "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 ( 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. */} {/* Phones: the whole card is tappable too; interactive children sit above it. */}
<Link <Link
href={`/deck/${deck.id}`} href={`/deck/${deck.id}`}
@ -474,7 +502,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
</div> </div>
) : null} ) : null}
</li> </motion.li>
); );
} }

View file

@ -1,24 +1,43 @@
"use client"; "use client";
import { motion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { BoltIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons"; import { BoltIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion";
import type { HomeworkRecord } from "@/lib/schemas"; import type { HomeworkRecord } from "@/lib/schemas";
import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils"; import { cn, formatDate, truncate } from "@/lib/utils";
/** /**
* One saved homework result in the library's Homework tab: the finished * One saved homework result in the library's Homework tab: the finished
* solution or the step-by-step guide, with open + delete. * 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 { t, plural } = useI18n();
const isPhone = useIsPhone();
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const href = `/homework?open=${record.id}`; 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 = const preview =
record.mode === "make" record.mode === "make"
? truncate(record.answer.replace(/\s+/g, " ").trim(), 150) ? truncate(record.answer.replace(/\s+/g, " ").trim(), 150)
@ -28,7 +47,14 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
); );
return ( 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. */} {/* 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" /> <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> </div>
</li> </motion.li>
); );
} }

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import { useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useMemo, useRef, useState, useSyncExternalStore } from "react";
@ -7,13 +8,16 @@ import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar"; import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard"; import { HomeworkCard } from "@/components/HomeworkCard";
import { FolderIcon, UploadIcon } from "@/components/icons"; import { FolderIcon, UploadIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, EmptyState } from "@/components/ui"; import { Button, EmptyState } from "@/components/ui";
import { folderStore, renameFolder } from "@/lib/folders"; import { folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework"; import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav"; import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { springGentle } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas"; import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage"; import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** /**
@ -38,6 +42,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
const selected = useSelectedFolder(); const selected = useSelectedFolder();
const setSelected = folderSelection.set; const setSelected = folderSelection.set;
const { t, plural } = useI18n(); const { t, plural } = useI18n();
const isPhone = useIsPhone();
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null); const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null); const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<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. */} {/* Two pools, never mixed: study decks and saved homework. */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="segmented min-w-0 flex-1 sm:max-w-sm"> <div className="segmented min-w-0 flex-1 sm:max-w-sm">
<Link href="/" aria-current={tab === "decks" ? "page" : undefined}> <Link
{t("tabDecks")} 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>
<Link href="/?tab=homework" aria-current={tab === "homework" ? "page" : undefined}> <Link
{t("tabHomework")} 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> </Link>
</div> </div>
<button <button
@ -203,6 +218,15 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
) : null} ) : null}
</section> </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 --------- */} {/* decks: folders + card grid, or a decks-only empty state --------- */}
{tab === "decks" && isEmpty ? ( {tab === "decks" && isEmpty ? (
<EmptyState <EmptyState
@ -321,19 +345,22 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</div> </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))]"> <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) => ( <AnimatePresence initial={false}>
<DeckCard {group.decks.map((deck, index) => (
key={deck.id} <DeckCard
deck={deck} key={deck.id}
onMove={(folderId) => { deck={deck}
upsertDeck({ ...deck, folderId }); index={index}
setMessage({ onMove={(folderId) => {
tone: "ok", upsertDeck({ ...deck, folderId });
text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"), setMessage({
}); tone: "ok",
}} text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"),
/> });
))} }}
/>
))}
</AnimatePresence>
</ul> </ul>
)} )}
</section> </section>
@ -358,13 +385,18 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{tab === "homework" && homework.length > 0 ? ( {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))]"> <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) => ( <AnimatePresence initial={false}>
<HomeworkCard key={record.id} record={record} onDelete={deleteHomework} /> {homework.map((record, index) => (
))} <HomeworkCard key={record.id} record={record} onDelete={deleteHomework} index={index} />
))}
</AnimatePresence>
</ul> </ul>
) : null} ) : null}
</motion.div>
</AnimatePresence>
</div> </div>
); );
} }

View file

@ -4,8 +4,10 @@ import Link from "next/link";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useCountUp } from "@/lib/motion";
import type { Activity, Deck } from "@/lib/schemas"; import type { Activity, Deck } from "@/lib/schemas";
import { downloadDeck } from "@/lib/storage"; import { downloadDeck } from "@/lib/storage";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons"; import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
import { Button, Chip } from "@/components/ui"; import { Button, Chip } from "@/components/ui";
@ -82,7 +84,11 @@ export function ResultsScreen({
regenerateError: string | null; regenerateError: string | null;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const isPhone = useIsPhone();
const percent = total > 0 ? Math.round((correct / total) * 100) : 0; 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 perfect = missed.length === 0 && total > 0;
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber"; const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
@ -93,7 +99,7 @@ export function ResultsScreen({
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="card flex flex-col items-center gap-3 p-6 text-center" 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"> <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")} {perfect ? t("results.perfect") : percent >= 60 ? t("results.nice") : t("results.start")}
</h1> </h1>
@ -109,7 +115,7 @@ export function ResultsScreen({
<div className="mt-1 flex flex-wrap justify-center gap-2"> <div className="mt-1 flex flex-wrap justify-center gap-2">
<Chip tone="accent"> <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>
<Chip tone="warning"> <Chip tone="warning">
<FlameIcon className="size-3.5" /> {t("results.bestStreak", { n: bestStreak })} <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 { t } = useI18n();
const radius = 52; const radius = 52;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
@ -232,7 +238,7 @@ function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "fros
/> />
</svg> </svg>
<div className="absolute flex flex-col items-center"> <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"> <span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
{t("results.score")} {t("results.score")}
</span> </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 { useEffect, useRef, useState } from "react";
import { LOCALES, useI18n, type Locale } from "@/lib/i18n"; import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install"; import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { clearApiKey, saveSettings } from "@/lib/settings"; import { clearApiKey, saveSettings } from "@/lib/settings";
@ -20,6 +21,7 @@ import {
XCircleIcon, XCircleIcon,
} from "@/components/icons"; } from "@/components/icons";
import { Button, Spinner } from "@/components/ui"; import { Button, Spinner } from "@/components/ui";
import { SegmentedPill } from "@/components/SegmentedPill";
/** “Checked just now”, or the clock time once the result is older. */ /** “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) { 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 }} initial={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 48, opacity: 0 }}
animate={reduceMotion ? { opacity: 1 } : { y: 0, opacity: 1 }} animate={reduceMotion ? { opacity: 1 } : { y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 32, opacity: 0 }} 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()} 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]" 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" type="button"
aria-pressed={locale === entry.id} aria-pressed={locale === entry.id}
onClick={() => setLocale(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> </button>
))} ))}
</div> </div>

View file

@ -18,6 +18,7 @@ import {
import { useTheme } from "@/components/ThemeToggle"; import { useTheme } from "@/components/ThemeToggle";
import { createFolder, folderStore } from "@/lib/folders"; import { createFolder, folderStore } from "@/lib/folders";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav"; import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
import { UNORGANIZED_ID } from "@/lib/schemas"; import { UNORGANIZED_ID } from "@/lib/schemas";
import { deckStore } from "@/lib/storage"; import { deckStore } from "@/lib/storage";
@ -416,7 +417,14 @@ export function MobileSidebar() {
initial={{ x: -280 }} initial={{ x: -280 }}
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: -280 }} 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" 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)} /> <NavContent onNavigate={() => sidebarOpen.set(false)} />

View file

@ -209,7 +209,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
const progressMax = slideCount; const progressMax = slideCount;
return ( 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 ---------------------------------------------------- */} {/* Chrome ---------------------------------------------------- */}
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3"> <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"> <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"; "use client";
import { motion, useReducedMotion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons"; import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springSnappy } from "@/lib/motion";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils"; 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 * 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 * phones; hidden on desktop (the header carries the same actions there) and on
* the full-screen study feed. * 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() { export function TabBar() {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useI18n(); 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 active = "text-[var(--text)]";
const idle = "text-[var(--text-subtle)]"; 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 ( return (
<nav <nav
aria-label={t("tabbar.main")} aria-label={t("tabbar.main")}
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden" 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)}> <Link
<HomeIcon className="size-6" /> href="/"
{t("common.library")} 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>
<Link <Link
href="/create" href="/create"
aria-current={pathname === "/create" ? "page" : undefined} aria-current={createActive ? "page" : undefined}
className={cn(item, pathname === "/create" ? active : idle)} className={cn(item, createActive ? active : idle)}
> >
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]"> {pill(createActive)}
<PlusIcon className="size-4" /> {icon(
</span> <span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
{t("common.newDeck")} <PlusIcon className="size-4" />
</span>,
createActive,
"create",
)}
{label(t("common.newDeck"), createActive)}
</Link> </Link>
<button type="button" onClick={openSettings} className={cn(item, idle)}> <button type="button" onClick={openSettings} className={cn(item, idle)}>
<GearIcon className="size-6" /> {icon(<GearIcon className="size-6" />, false, "settings")}
{t("common.settings")} {label(t("common.settings"), false)}
</button> </button>
</nav> </nav>
); );

View file

@ -28,12 +28,14 @@ import {
} from "@/lib/image"; } from "@/lib/image";
import { createFolder, folderStore } from "@/lib/folders"; import { createFolder, folderStore } from "@/lib/folders";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { stepVariants } from "@/lib/motion";
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas"; import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { upsertDeck } from "@/lib/storage"; import { upsertDeck } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
import { useIsPhone } from "@/lib/use-phone"; import { useIsPhone } from "@/lib/use-phone";
import { cn, uid } from "@/lib/utils"; import { cn, uid } from "@/lib/utils";
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons"; import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, ProgressBar } from "@/components/ui"; import { Button, ProgressBar } from "@/components/ui";
type Phase = "idle" | "preparing" | "loading" | "error"; type Phase = "idle" | "preparing" | "loading" | "error";
@ -103,6 +105,8 @@ export function UploadScreen() {
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "" }); const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "" });
/** Phones only: which step of the 3-step flow is showing. */ /** Phones only: which step of the 3-step flow is showing. */
const [step, setStep] = useState(1); 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. */ /** Phones only: the “paste text instead” field on step 1. */
const [pasteOpen, setPasteOpen] = useState(false); const [pasteOpen, setPasteOpen] = useState(false);
const isPhone = useIsPhone(); const isPhone = useIsPhone();
@ -407,17 +411,26 @@ export function UploadScreen() {
aria-checked={selected} aria-checked={selected}
onClick={() => setDifficulty(option.value)} onClick={() => setDifficulty(option.value)}
className={cn( className={cn(
"flex flex-col items-center gap-1 rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors", "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 selected && isPhone
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20" ? "border-transparent"
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]", : 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} {option.icon}
</span> </span>
<span className="text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span> <span className="relative z-10 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 hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
</button> </button>
); );
})} })}
@ -436,13 +449,16 @@ export function UploadScreen() {
aria-pressed={count === option} aria-pressed={count === option}
onClick={() => setCount(option)} onClick={() => setCount(option)}
className={cn( className={cn(
"min-w-12 rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition", "relative min-w-12 overflow-hidden rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
count === option count === option && isPhone
? "grad-ice border-transparent text-[#06111f]" ? "border-transparent"
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]", : 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> </button>
))} ))}
</div> </div>
@ -559,9 +575,9 @@ export function UploadScreen() {
<motion.div <motion.div
key={step} key={step}
initial={reduceMotion ? false : { opacity: 0, x: 18 }} initial="initial"
animate={{ opacity: 1, x: 0 }} animate="animate"
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }} variants={stepVariants(stepDir, !!reduceMotion)}
className="mt-5" className="mt-5"
> >
{step === 1 ? ( {step === 1 ? (
@ -732,7 +748,10 @@ export function UploadScreen() {
variant="secondary" variant="secondary"
size="lg" size="lg"
className="touch-target flex-1" 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} disabled={step === 1}
> >
{t("common.back")} {t("common.back")}
@ -741,7 +760,10 @@ export function UploadScreen() {
<Button <Button
size="lg" size="lg"
className="touch-target flex-[1.5]" 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")} {t("common.next")}
</Button> </Button>
@ -1054,6 +1076,7 @@ const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; keys: string[] }> =
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) { function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
const { t } = useI18n(); const { t } = useI18n();
const reduceMotion = useReducedMotion();
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const [tip, setTip] = useState(0); const [tip, setTip] = useState(0);
@ -1089,9 +1112,16 @@ function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCa
</motion.div> </motion.div>
<div className="relative w-full max-w-md space-y-3"> <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} {message}
</p> </motion.p>
<ProgressBar value={activeIndex + 1} max={2} label={t("upload.generationProgress")} /> <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)]"> <ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
{STAGE_STEPS.map((step, index) => ( {STAGE_STEPS.map((step, index) => (

View file

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

View file

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

View file

@ -121,7 +121,7 @@ export function ProgressBar({
aria-label={label ?? "Progress"} aria-label={label ?? "Progress"}
> >
<div <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}%` }} style={{ width: `${percent}%` }}
/> />
</div> </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. * 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;