From 374752c59ed403ebfb39a6d4c42c44a326968673 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Sat, 10 Oct 2026 11:01:29 +0000 Subject: [PATCH] Animation pass: one shared motion language for phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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. --- README.md | 20 +++++ app/create/page.tsx | 5 +- app/globals.css | 123 ++++++++++++++++++++++++++ app/homework/page.tsx | 5 +- app/layout.tsx | 2 +- app/page.tsx | 5 +- components/DeckCard.tsx | 34 ++++++- components/HomeworkCard.tsx | 32 ++++++- components/LibraryScreen.tsx | 72 ++++++++++----- components/ResultsScreen.tsx | 14 ++- components/RouteTransition.tsx | 46 ++++++++++ components/SegmentedPill.tsx | 36 ++++++++ components/SettingsDialog.tsx | 8 +- components/Sidebar.tsx | 10 ++- components/StudyFeed.tsx | 2 +- components/TabBar.tsx | 71 ++++++++++++--- components/UploadScreen.tsx | 68 ++++++++++---- components/activities/shared.tsx | 7 +- components/homework/HomeworkInput.tsx | 23 +++-- components/ui.tsx | 2 +- lib/motion.ts | 123 ++++++++++++++++++++++++++ public/sw.js | 2 +- 22 files changed, 633 insertions(+), 77 deletions(-) create mode 100644 components/RouteTransition.tsx create mode 100644 components/SegmentedPill.tsx create mode 100644 lib/motion.ts diff --git a/README.md b/README.md index 2d4d106..8092d27 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/create/page.tsx b/app/create/page.tsx index cb95c90..58a62f1 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -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({
- + + +
diff --git a/app/globals.css b/app/globals.css index 0fb68aa..49638ab 100644 --- a/app/globals.css +++ b/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 } } diff --git a/app/homework/page.tsx b/app/homework/page.tsx index 8930e05..2c4af0a 100644 --- a/app/homework/page.tsx +++ b/app/homework/page.tsx @@ -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({
- + + +
diff --git a/app/layout.tsx b/app/layout.tsx index d18df27..3b1041f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -78,7 +78,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })