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

@ -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>