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 })
-
+
{children}
diff --git a/app/page.tsx b/app/page.tsx
index cec60b5..4a8d0bf 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -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({
-
+
+
+
diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx
index bb9fb10..4798c57 100644
--- a/components/DeckCard.tsx
+++ b/components/DeckCard.tsx
@@ -1,14 +1,17 @@
"use client";
import Link from "next/link";
+import { motion } from "framer-motion";
import { useState } from "react";
import { ApiError, generateDeck } from "@/lib/client-api";
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
import { createFolder as makeFolder, listFolders } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
+import { springGentle } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
+import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils";
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
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
* 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 isPhone = useIsPhone();
const folders = listFolders();
const [busy, setBusy] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -110,8 +123,23 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
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)]";
+ /* 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 (
-
+
{/* Phones: the whole card is tappable too; interactive children sit above it. */}
) : null}
-
+
);
}
diff --git a/components/HomeworkCard.tsx b/components/HomeworkCard.tsx
index 067c1dc..3cfdfa5 100644
--- a/components/HomeworkCard.tsx
+++ b/components/HomeworkCard.tsx
@@ -1,24 +1,43 @@
"use client";
+import { motion } from "framer-motion";
import Link from "next/link";
import { useState } from "react";
import { BoltIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
+import { springGentle } from "@/lib/motion";
import type { HomeworkRecord } from "@/lib/schemas";
+import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils";
/**
* One saved homework result in the library's Homework tab: the finished
* 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 isPhone = useIsPhone();
const [confirmDelete, setConfirmDelete] = useState(false);
const [expanded, setExpanded] = useState(false);
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 =
record.mode === "make"
? truncate(record.answer.replace(/\s+/g, " ").trim(), 150)
@@ -28,7 +47,14 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
);
return (
-
+
{/* Phones: the whole card opens the result too; the delete button sits above it. */}
@@ -113,6 +139,6 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
>
)}
-
+
);
}
diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx
index 384ba1c..872040e 100644
--- a/components/LibraryScreen.tsx
+++ b/components/LibraryScreen.tsx
@@ -1,5 +1,6 @@
"use client";
+import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link";
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
@@ -7,13 +8,16 @@ import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
import { FolderIcon, UploadIcon } from "@/components/icons";
+import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, EmptyState } from "@/components/ui";
import { folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { useI18n } from "@/lib/i18n";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
+import { springGentle } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
+import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
/**
@@ -38,6 +42,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
const selected = useSelectedFolder();
const setSelected = folderSelection.set;
const { t, plural } = useI18n();
+ const isPhone = useIsPhone();
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState(null);
@@ -149,11 +154,21 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{/* Two pools, never mixed: study decks and saved homework. */}