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
20
README.md
20
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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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">
|
||||
<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">
|
||||
<RouteTransition>
|
||||
<LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} />
|
||||
</RouteTransition>
|
||||
</div>
|
||||
<TabBar />
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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<Busy>(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 (
|
||||
<li className="card card-hover group relative flex flex-col p-4 sm:p-5">
|
||||
<motion.li
|
||||
layout={isPhone}
|
||||
variants={isPhone ? cardVariants : undefined}
|
||||
initial={isPhone ? "hidden" : false}
|
||||
animate={isPhone ? "show" : undefined}
|
||||
exit={isPhone ? "exit" : undefined}
|
||||
className="card card-hover group relative flex flex-col p-4 sm:p-5"
|
||||
>
|
||||
{/* Phones: the whole card is tappable too; interactive children sit above it. */}
|
||||
<Link
|
||||
href={`/deck/${deck.id}`}
|
||||
|
|
@ -474,7 +502,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
</li>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<li className="card card-hover group relative flex flex-col p-4 sm:p-5">
|
||||
<motion.li
|
||||
layout={isPhone}
|
||||
variants={isPhone ? cardVariants : undefined}
|
||||
initial={isPhone ? "hidden" : false}
|
||||
animate={isPhone ? "show" : undefined}
|
||||
exit={isPhone ? "exit" : undefined}
|
||||
className="card card-hover group relative flex flex-col p-4 sm:p-5"
|
||||
>
|
||||
{/* Phones: the whole card opens the result too; the delete button sits above it. */}
|
||||
<Link href={href} aria-hidden="true" tabIndex={-1} className="absolute inset-0 hidden rounded-[1.5rem] max-sm:block lg:block" />
|
||||
|
||||
|
|
@ -113,6 +139,6 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
|
@ -149,11 +154,21 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
{/* Two pools, never mixed: study decks and saved homework. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="segmented min-w-0 flex-1 sm:max-w-sm">
|
||||
<Link href="/" aria-current={tab === "decks" ? "page" : undefined}>
|
||||
{t("tabDecks")}
|
||||
<Link
|
||||
href="/"
|
||||
aria-current={tab === "decks" ? "page" : undefined}
|
||||
className={cn("relative", isPhone && "seg-slide")}
|
||||
>
|
||||
{isPhone && tab === "decks" ? <SegmentedPill layoutId="library-tabs" /> : null}
|
||||
<span className="seg-label relative z-10">{t("tabDecks")}</span>
|
||||
</Link>
|
||||
<Link href="/?tab=homework" aria-current={tab === "homework" ? "page" : undefined}>
|
||||
{t("tabHomework")}
|
||||
<Link
|
||||
href="/?tab=homework"
|
||||
aria-current={tab === "homework" ? "page" : undefined}
|
||||
className={cn("relative", isPhone && "seg-slide")}
|
||||
>
|
||||
{isPhone && tab === "homework" ? <SegmentedPill layoutId="library-tabs" /> : null}
|
||||
<span className="seg-label relative z-10">{t("tabHomework")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -203,6 +218,15 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Phones: switching pools crossfades the content. */}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={isPhone ? tab : "static"}
|
||||
initial={isPhone ? { opacity: 0, y: 8 } : false}
|
||||
animate={isPhone ? { opacity: 1, y: 0 } : undefined}
|
||||
exit={isPhone ? { opacity: 0, y: -6, transition: { duration: 0.16 } } : undefined}
|
||||
transition={springGentle}
|
||||
>
|
||||
{/* decks: folders + card grid, or a decks-only empty state --------- */}
|
||||
{tab === "decks" && isEmpty ? (
|
||||
<EmptyState
|
||||
|
|
@ -321,10 +345,12 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
</div>
|
||||
) : (
|
||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
|
||||
{group.decks.map((deck) => (
|
||||
<AnimatePresence initial={false}>
|
||||
{group.decks.map((deck, index) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
index={index}
|
||||
onMove={(folderId) => {
|
||||
upsertDeck({ ...deck, folderId });
|
||||
setMessage({
|
||||
|
|
@ -334,6 +360,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
}}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -358,13 +385,18 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
|
||||
{tab === "homework" && homework.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
|
||||
{homework.map((record) => (
|
||||
<HomeworkCard key={record.id} record={record} onDelete={deleteHomework} />
|
||||
<AnimatePresence initial={false}>
|
||||
{homework.map((record, index) => (
|
||||
<HomeworkCard key={record.id} record={record} onDelete={deleteHomework} index={index} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import Link from "next/link";
|
|||
import { motion } from "framer-motion";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useCountUp } from "@/lib/motion";
|
||||
import type { Activity, Deck } from "@/lib/schemas";
|
||||
import { downloadDeck } from "@/lib/storage";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
|
||||
import { Button, Chip } from "@/components/ui";
|
||||
|
|
@ -82,7 +84,11 @@ export function ResultsScreen({
|
|||
regenerateError: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
/* Phones: the score and XP count up; desktop jumps straight in. */
|
||||
const percentShown = useCountUp(percent, !isPhone);
|
||||
const xpShown = useCountUp(xp, !isPhone);
|
||||
const perfect = missed.length === 0 && total > 0;
|
||||
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
|
||||
|
||||
|
|
@ -93,7 +99,7 @@ export function ResultsScreen({
|
|||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card flex flex-col items-center gap-3 p-6 text-center"
|
||||
>
|
||||
<ScoreRing percent={percent} tone={tone} />
|
||||
<ScoreRing percent={percent} display={percentShown} tone={tone} />
|
||||
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
|
||||
{perfect ? t("results.perfect") : percent >= 60 ? t("results.nice") : t("results.start")}
|
||||
</h1>
|
||||
|
|
@ -109,7 +115,7 @@ export function ResultsScreen({
|
|||
|
||||
<div className="mt-1 flex flex-wrap justify-center gap-2">
|
||||
<Chip tone="accent">
|
||||
<BoltIcon className="size-3.5" /> {t("results.xpEarned", { n: xp })}
|
||||
<BoltIcon className="size-3.5" /> {t("results.xpEarned", { n: xpShown })}
|
||||
</Chip>
|
||||
<Chip tone="warning">
|
||||
<FlameIcon className="size-3.5" /> {t("results.bestStreak", { n: bestStreak })}
|
||||
|
|
@ -203,7 +209,7 @@ export function ResultsScreen({
|
|||
);
|
||||
}
|
||||
|
||||
function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "frost" | "amber" }) {
|
||||
function ScoreRing({ percent, display, tone }: { percent: number; display: number; tone: "emerald" | "frost" | "amber" }) {
|
||||
const { t } = useI18n();
|
||||
const radius = 52;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
|
@ -232,7 +238,7 @@ function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "fros
|
|||
/>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center">
|
||||
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{percent}%</span>
|
||||
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{display}%</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
|
||||
{t("results.score")}
|
||||
</span>
|
||||
|
|
|
|||
46
components/RouteTransition.tsx
Normal file
46
components/RouteTransition.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { screenVariants, tabVariants } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* Direction-aware screen entrance for the page content column.
|
||||
*
|
||||
* Deeper paths (library → deck, library → create/homework) slide in from the
|
||||
* right; going back slides from the left; same-depth switches crossfade.
|
||||
* The wrapper only ever wraps the scrolling content column — fixed chrome
|
||||
* (tab bar, sheets) stays outside it, so a transform can never capture it.
|
||||
*
|
||||
* Note: the App Router unmounts the outgoing page immediately, so this is an
|
||||
* entrance animation (exits animate for in-page elements via AnimatePresence,
|
||||
* e.g. list items).
|
||||
*/
|
||||
|
||||
/** Module-level so the direction survives the per-route remount. */
|
||||
let lastPath: string | null = null;
|
||||
|
||||
function depth(path: string): number {
|
||||
if (path === "/") return 0;
|
||||
return path.split("/").filter(Boolean).length;
|
||||
}
|
||||
|
||||
export function RouteTransition({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const previous = lastPath;
|
||||
lastPath = pathname;
|
||||
|
||||
const isTabSwitch = previous !== null && depth(previous) === depth(pathname) && previous !== pathname;
|
||||
const direction: 1 | -1 =
|
||||
previous === null || depth(pathname) >= depth(previous) ? 1 : -1;
|
||||
const variants = isTabSwitch ? tabVariants(!!reduceMotion) : screenVariants(direction, !!reduceMotion);
|
||||
|
||||
return (
|
||||
<motion.div initial="initial" animate="animate" variants={variants}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
36
components/SegmentedPill.tsx
Normal file
36
components/SegmentedPill.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { springSnappy } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The gliding highlight shared by segmented controls and pickers. One
|
||||
* `layoutId` per control makes the pill slide between its items; under
|
||||
* reduced motion it simply appears in place.
|
||||
*/
|
||||
export function SegmentedPill({
|
||||
layoutId,
|
||||
className,
|
||||
plain,
|
||||
}: {
|
||||
layoutId: string;
|
||||
className?: string;
|
||||
/** Skip the ice gradient (for pickers that keep their own selected look). */
|
||||
plain?: boolean;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const base = plain ? "slide-pill" : "slide-pill grad-ice shadow-[0_6px_16px_-8px_rgb(56_189_248/0.7)]";
|
||||
if (reduceMotion) {
|
||||
return <span aria-hidden className={cn(base, className)} />;
|
||||
}
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId={layoutId}
|
||||
transition={springSnappy}
|
||||
className={cn(base, className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { AnimatePresence, motion, useDragControls, useReducedMotion } from "fram
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
|
||||
import { clearApiKey, saveSettings } from "@/lib/settings";
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
XCircleIcon,
|
||||
} from "@/components/icons";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||
|
||||
/** “Checked just now”, or the clock time once the result is older. */
|
||||
function freshnessLabel(checkedAt: number, locale: Locale, t: (key: string, vars?: Record<string, string>) => string) {
|
||||
|
|
@ -162,7 +164,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
initial={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 48, opacity: 0 }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0, opacity: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : phone ? { y: "100%", opacity: 0 } : { y: 32, opacity: 0 }}
|
||||
transition={phone ? { duration: 0.34, ease: [0.22, 1, 0.36, 1] } : { type: "spring", stiffness: 340, damping: 32 }}
|
||||
transition={phone ? springSheet : { type: "spring", stiffness: 340, damping: 32 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="glass-panel relative flex max-h-[90dvh] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.6rem] shadow-2xl sm:rounded-[1.35rem]"
|
||||
>
|
||||
|
|
@ -220,8 +222,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
type="button"
|
||||
aria-pressed={locale === entry.id}
|
||||
onClick={() => setLocale(entry.id)}
|
||||
className={cn("relative", phone && "seg-slide")}
|
||||
>
|
||||
{entry.label}
|
||||
{phone && locale === entry.id ? <SegmentedPill layoutId="language-pill" /> : null}
|
||||
<span className="seg-label relative z-10">{entry.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { useTheme } from "@/components/ThemeToggle";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
|
||||
import { UNORGANIZED_ID } from "@/lib/schemas";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
|
|
@ -416,7 +417,14 @@ export function MobileSidebar() {
|
|||
initial={{ x: -280 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -280 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 36 }}
|
||||
transition={springSheet}
|
||||
drag="x"
|
||||
dragConstraints={{ left: -280, right: 0 }}
|
||||
dragElastic={{ left: 0.45, right: 0 }}
|
||||
dragMomentum={false}
|
||||
onDragEnd={(_, gesture) => {
|
||||
if (gesture.offset.x < -80 || gesture.velocity.x < -500) sidebarOpen.set(false);
|
||||
}}
|
||||
className="sidebar-surface relative z-10 flex h-full w-[272px] flex-col rounded-l-none rounded-r-[1.5rem] px-2.5 py-3 pb-safe"
|
||||
>
|
||||
<NavContent onNavigate={() => sidebarOpen.set(false)} />
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
const progressMax = slideCount;
|
||||
|
||||
return (
|
||||
<div className="frost-bg fixed inset-0 flex flex-col">
|
||||
<div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile">
|
||||
{/* Chrome ---------------------------------------------------- */}
|
||||
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
||||
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSnappy } from "@/lib/motion";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -12,37 +14,82 @@ import { cn } from "@/lib/utils";
|
|||
* iOS-style bottom tab bar. Rendered on the library and create screens on
|
||||
* phones; hidden on desktop (the header carries the same actions there) and on
|
||||
* the full-screen study feed.
|
||||
*
|
||||
* The active item sits on a sliding pill (shared `layoutId`), and its icon
|
||||
* gives a small bounce when you land on it.
|
||||
*/
|
||||
export function TabBar() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
|
||||
const item =
|
||||
"relative flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
|
||||
const active = "text-[var(--text)]";
|
||||
const idle = "text-[var(--text-subtle)]";
|
||||
|
||||
/** The pill glides between items; hidden under reduced motion. */
|
||||
const pill = (isActive: boolean) =>
|
||||
isActive && !reduceMotion ? (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
layoutId="tabbar-active"
|
||||
transition={springSnappy}
|
||||
className="absolute inset-x-2 inset-y-0.5 rounded-2xl bg-[var(--nav-active-bg)] shadow-[inset_0_0_0_1px_var(--nav-active-border)]"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
/** Small icon bounce on the item you just selected. */
|
||||
const icon = (children: React.ReactNode, isActive: boolean, key: string) => (
|
||||
<motion.span
|
||||
key={`${key}-${isActive ? pathname : "idle"}`}
|
||||
animate={isActive && !reduceMotion ? { scale: [1, 1.16, 1] } : { scale: 1 }}
|
||||
transition={springSnappy}
|
||||
className="relative z-10 grid place-items-center"
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
|
||||
const label = (text: string, isActive: boolean) => (
|
||||
<span className={cn("relative z-10", isActive ? active : idle)}>{text}</span>
|
||||
);
|
||||
|
||||
const libraryActive = pathname === "/";
|
||||
const createActive = pathname === "/create";
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("tabbar.main")}
|
||||
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
|
||||
>
|
||||
<Link href="/" aria-current={pathname === "/" ? "page" : undefined} className={cn(item, pathname === "/" ? active : idle)}>
|
||||
<HomeIcon className="size-6" />
|
||||
{t("common.library")}
|
||||
<Link
|
||||
href="/"
|
||||
aria-current={libraryActive ? "page" : undefined}
|
||||
className={cn(item, libraryActive ? active : idle)}
|
||||
>
|
||||
{pill(libraryActive)}
|
||||
{icon(<HomeIcon className="size-6" />, libraryActive, "library")}
|
||||
{label(t("common.library"), libraryActive)}
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
aria-current={pathname === "/create" ? "page" : undefined}
|
||||
className={cn(item, pathname === "/create" ? active : idle)}
|
||||
aria-current={createActive ? "page" : undefined}
|
||||
className={cn(item, createActive ? active : idle)}
|
||||
>
|
||||
{pill(createActive)}
|
||||
{icon(
|
||||
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
|
||||
<PlusIcon className="size-4" />
|
||||
</span>
|
||||
{t("common.newDeck")}
|
||||
</span>,
|
||||
createActive,
|
||||
"create",
|
||||
)}
|
||||
{label(t("common.newDeck"), createActive)}
|
||||
</Link>
|
||||
<button type="button" onClick={openSettings} className={cn(item, idle)}>
|
||||
<GearIcon className="size-6" />
|
||||
{t("common.settings")}
|
||||
{icon(<GearIcon className="size-6" />, false, "settings")}
|
||||
{label(t("common.settings"), false)}
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ import {
|
|||
} from "@/lib/image";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { stepVariants } from "@/lib/motion";
|
||||
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
||||
import { upsertDeck } from "@/lib/storage";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn, uid } from "@/lib/utils";
|
||||
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
|
@ -103,6 +105,8 @@ export function UploadScreen() {
|
|||
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "" });
|
||||
/** Phones only: which step of the 3-step flow is showing. */
|
||||
const [step, setStep] = useState(1);
|
||||
/** 1 = moving forward (slides from the right), -1 = Back (from the left). */
|
||||
const [stepDir, setStepDir] = useState<1 | -1>(1);
|
||||
/** Phones only: the “paste text instead” field on step 1. */
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
const isPhone = useIsPhone();
|
||||
|
|
@ -407,17 +411,26 @@ export function UploadScreen() {
|
|||
aria-checked={selected}
|
||||
onClick={() => setDifficulty(option.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
|
||||
selected
|
||||
"relative flex flex-col items-center gap-1 overflow-hidden rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
|
||||
selected && isPhone
|
||||
? "border-transparent"
|
||||
: selected
|
||||
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
|
||||
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-frost-600 dark:text-frost-200">
|
||||
{selected && isPhone ? (
|
||||
<SegmentedPill
|
||||
layoutId="difficulty-pill"
|
||||
plain
|
||||
className="rounded-2xl border border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
|
||||
/>
|
||||
) : null}
|
||||
<span aria-hidden className="relative z-10 text-frost-600 dark:text-frost-200">
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span>
|
||||
<span className="hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
|
||||
<span className="relative z-10 text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span>
|
||||
<span className="relative z-10 hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -436,13 +449,16 @@ export function UploadScreen() {
|
|||
aria-pressed={count === option}
|
||||
onClick={() => setCount(option)}
|
||||
className={cn(
|
||||
"min-w-12 rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
|
||||
count === option
|
||||
"relative min-w-12 overflow-hidden rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
|
||||
count === option && isPhone
|
||||
? "border-transparent"
|
||||
: count === option
|
||||
? "grad-ice border-transparent text-[#06111f]"
|
||||
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
{count === option && isPhone ? <SegmentedPill layoutId="count-pill" className="rounded-xl" /> : null}
|
||||
<span className={cn("relative z-10", count === option && isPhone && "text-[#06111f]")}>{option}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -559,9 +575,9 @@ export function UploadScreen() {
|
|||
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={reduceMotion ? false : { opacity: 0, x: 18 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={stepVariants(stepDir, !!reduceMotion)}
|
||||
className="mt-5"
|
||||
>
|
||||
{step === 1 ? (
|
||||
|
|
@ -732,7 +748,10 @@ export function UploadScreen() {
|
|||
variant="secondary"
|
||||
size="lg"
|
||||
className="touch-target flex-1"
|
||||
onClick={() => setStep((value) => Math.max(1, value - 1))}
|
||||
onClick={() => {
|
||||
setStepDir(-1);
|
||||
setStep((value) => Math.max(1, value - 1));
|
||||
}}
|
||||
disabled={step === 1}
|
||||
>
|
||||
{t("common.back")}
|
||||
|
|
@ -741,7 +760,10 @@ export function UploadScreen() {
|
|||
<Button
|
||||
size="lg"
|
||||
className="touch-target flex-[1.5]"
|
||||
onClick={() => setStep((value) => Math.min(3, value + 1))}
|
||||
onClick={() => {
|
||||
setStepDir(1);
|
||||
setStep((value) => Math.min(3, value + 1));
|
||||
}}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
|
|
@ -1054,6 +1076,7 @@ const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; keys: string[] }> =
|
|||
|
||||
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [tick, setTick] = useState(0);
|
||||
const [tip, setTip] = useState(0);
|
||||
|
||||
|
|
@ -1089,9 +1112,16 @@ function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCa
|
|||
</motion.div>
|
||||
|
||||
<div className="relative w-full max-w-md space-y-3">
|
||||
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
|
||||
<motion.p
|
||||
key={message}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="text-xl font-extrabold text-[var(--text)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</motion.p>
|
||||
<ProgressBar value={activeIndex + 1} max={2} label={t("upload.generationProgress")} />
|
||||
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{STAGE_STEPS.map((step, index) => (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
|
|
@ -152,6 +153,8 @@ export function FeedbackPanel({
|
|||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
|
|
@ -171,6 +174,8 @@ export function FeedbackPanel({
|
|||
className={cn(
|
||||
"flex items-center gap-2 text-base font-extrabold",
|
||||
correct ? "text-emerald-900 dark:text-emerald-200" : "text-rose-900 dark:text-rose-200",
|
||||
/* Phones: a soft pulse on correct, a quick shake on wrong. */
|
||||
isPhone && !reduceMotion && (correct ? "animate-pop" : "animate-wiggle"),
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{correct ? "✅" : "💧"}</span>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
||||
import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { stepVariants } from "@/lib/motion";
|
||||
import type { useNotesIntake } from "@/lib/use-notes-intake";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
|
|
@ -68,6 +69,7 @@ export function HomeworkInput({
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const [step, setStep] = useState(1);
|
||||
const [stepDir, setStepDir] = useState<1 | -1>(1);
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
|
@ -237,9 +239,9 @@ export function HomeworkInput({
|
|||
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={reduceMotion ? false : { opacity: 0, x: 18 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={stepVariants(stepDir, !!reduceMotion)}
|
||||
className="mt-5"
|
||||
>
|
||||
{step === 1 ? (
|
||||
|
|
@ -396,13 +398,24 @@ export function HomeworkInput({
|
|||
variant="secondary"
|
||||
size="lg"
|
||||
className="touch-target flex-1"
|
||||
onClick={() => setStep(1)}
|
||||
onClick={() => {
|
||||
setStepDir(-1);
|
||||
setStep(1);
|
||||
}}
|
||||
disabled={step === 1}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
{step === 1 ? (
|
||||
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => setStep(2)} disabled={!canSubmit}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="touch-target flex-[1.5]"
|
||||
onClick={() => {
|
||||
setStepDir(1);
|
||||
setStep(2);
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export function ProgressBar({
|
|||
aria-label={label ?? "Progress"}
|
||||
>
|
||||
<div
|
||||
className="grad-ice h-full rounded-full transition-[width] duration-500 ease-out"
|
||||
className="grad-ice h-full rounded-full transition-[width] duration-[420ms] [transition-timing-function:cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
123
lib/motion.ts
Normal file
123
lib/motion.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* One shared motion language for the mobile animation pass.
|
||||
*
|
||||
* Every component imports its presets from here instead of inventing ad-hoc
|
||||
* springs, so the whole app feels like one native surface:
|
||||
*
|
||||
* - `springSnappy` — press releases and small UI changes (180–220ms feel).
|
||||
* - `springGentle` — screen content, lists, feedback panels (280–350ms).
|
||||
* - `springSheet` — bottom sheets and the drawer, tuned for dragging.
|
||||
* - `EASE_OUT` — quick fades that don't need a spring.
|
||||
*
|
||||
* Only transform and opacity are ever animated (plus the explicit height
|
||||
* animation of expanders); blur and backdrop-filter are never touched.
|
||||
* `prefers-reduced-motion` is handled by the callers (CSS media query for
|
||||
* press feedback, `useReducedMotion` for JS springs) so motion collapses to
|
||||
* short fades without changing layout.
|
||||
*/
|
||||
|
||||
import type { Transition, Variants } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Press feedback and other tiny changes. ~200ms spring, no bounce overshoot. */
|
||||
export const springSnappy: Transition = { type: "spring", stiffness: 520, damping: 34, mass: 0.7 };
|
||||
|
||||
/** Content entering/exiting a screen or list. ~300ms, a whisper of overshoot. */
|
||||
export const springGentle: Transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.9 };
|
||||
|
||||
/** Sheets and the drawer: responsive to release velocity, settles fast. */
|
||||
export const springSheet: Transition = { type: "spring", stiffness: 380, damping: 36, mass: 1 };
|
||||
|
||||
/** Crisp fade for elements that should not move. */
|
||||
export const fade: Transition = { duration: 0.18, ease: "easeOut" };
|
||||
|
||||
/** Plain ease for fades and progress bars. */
|
||||
export const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
/** Durations (seconds) used across the pass. */
|
||||
export const DURATION = {
|
||||
press: 0.1,
|
||||
small: 0.2,
|
||||
screen: 0.3,
|
||||
sheet: 0.34,
|
||||
} as const;
|
||||
|
||||
/** A screen entering from the right (drill-in) or left (back). */
|
||||
export function screenVariants(direction: 1 | -1, reduceMotion: boolean): Variants {
|
||||
if (reduceMotion) {
|
||||
return {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, transition: { duration: 0.12 } },
|
||||
exit: { opacity: 0, transition: { duration: 0.1 } },
|
||||
};
|
||||
}
|
||||
return {
|
||||
initial: { opacity: 0, x: 24 * direction },
|
||||
animate: { opacity: 1, x: 0, transition: springGentle },
|
||||
exit: { opacity: 0, x: -16 * direction, transition: { duration: 0.18, ease: "easeIn" } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Crossfade for same-depth route switches (tab to tab). */
|
||||
export function tabVariants(reduceMotion: boolean): Variants {
|
||||
if (reduceMotion) {
|
||||
return { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } };
|
||||
}
|
||||
return {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
animate: { opacity: 1, y: 0, transition: springGentle },
|
||||
exit: { opacity: 0, y: -6, transition: { duration: 0.16, ease: "easeIn" } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Wizard step: forward slides in from the right, Back from the left. */
|
||||
export function stepVariants(direction: 1 | -1, reduceMotion: boolean): Variants {
|
||||
if (reduceMotion) {
|
||||
return { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.12 } } };
|
||||
}
|
||||
return {
|
||||
initial: { opacity: 0, x: 18 * direction },
|
||||
animate: { opacity: 1, x: 0, transition: { duration: 0.24, ease: EASE_OUT } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Capped stagger for a list's first paint. */
|
||||
export const listContainer: Variants = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.045, delayChildren: 0.02 } },
|
||||
};
|
||||
|
||||
export const listItem: Variants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: { opacity: 1, y: 0, transition: springGentle },
|
||||
};
|
||||
|
||||
/**
|
||||
* Counts a number up over ~600ms with an ease-out curve (score, XP). Pass
|
||||
* `disabled` (desktop, reduced motion) to jump straight to the value.
|
||||
*/
|
||||
export function useCountUp(target: number, disabled = false): number {
|
||||
const [value, setValue] = useState(disabled ? target : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setValue(target);
|
||||
return;
|
||||
}
|
||||
let frame = 0;
|
||||
const start = performance.now();
|
||||
const duration = 600;
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - start) / duration);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setValue(Math.round(target * eased));
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [target, disabled]);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v20";
|
||||
const CACHE_VERSION = "freezmo-v21";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue