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:
parent
6564e88138
commit
374752c59e
22 changed files with 633 additions and 77 deletions
|
|
@ -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>
|
||||
|
|
|
|||
123
app/globals.css
123
app/globals.css
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue