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 })