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
|
|
@ -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,19 +345,22 @@ 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) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
onMove={(folderId) => {
|
||||
upsertDeck({ ...deck, folderId });
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<AnimatePresence initial={false}>
|
||||
{group.decks.map((deck, index) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
index={index}
|
||||
onMove={(folderId) => {
|
||||
upsertDeck({ ...deck, folderId });
|
||||
setMessage({
|
||||
tone: "ok",
|
||||
text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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)}
|
||||
>
|
||||
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
|
||||
<PlusIcon className="size-4" />
|
||||
</span>
|
||||
{t("common.newDeck")}
|
||||
{pill(createActive)}
|
||||
{icon(
|
||||
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
|
||||
<PlusIcon className="size-4" />
|
||||
</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
|
||||
? "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]",
|
||||
"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
|
||||
? "grad-ice border-transparent text-[#06111f]"
|
||||
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||||
"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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue