From bb5750e9289cceea94b27791ca6ddd014cf2d2b6 Mon Sep 17 00:00:00 2001 From: nh9961 Date: Sun, 19 Jul 2026 20:45:47 +0000 Subject: [PATCH] feat(ui): unified motion language and interaction states - Shared transition constants (view/modal/toast/tile) with standard ease-out - MotionConfig reducedMotion=user plus CSS reduced-motion neutralizer - Consistent hover/press/focus-visible states across all interactive classes --- AGENTS.md | 4 + src/App.tsx | 3 +- src/components/BarcodeScannerModal.tsx | 3 +- src/components/ConfirmDialog.tsx | 2 + src/components/EntryModal.tsx | 3 +- src/components/ImportPreviewModal.tsx | 2 + src/components/MetricTiles.tsx | 3 +- src/components/Modal.tsx | 2 + src/components/Toasts.tsx | 3 +- src/components/TopBar.tsx | 4 +- src/index.css | 105 ++++++++++++++++++++++++- src/lib/motion.ts | 9 +++ src/main.tsx | 9 ++- 13 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 src/lib/motion.ts diff --git a/AGENTS.md b/AGENTS.md index 0d4e8e4..e88aeed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,10 @@ The repository does not currently feature automated test files. Testing is execu can be integrated by adding definitions to the `APP_THEMES` array in `src/data/themes.ts`. - **Proxy Endpoint Rerouting**: The Ollama upstream proxy route in `vite.config.ts` and `api/ollama-chat.js` can be adjusted to point to alternative LLM hosts or local server instances. +- **Motion Language**: Shared animation durations and the standard ease-out curve live in + `src/lib/motion.ts` (`VIEW_TRANSITION`, `MODAL_TRANSITION`, `TOAST_TRANSITION`, `TILE_TRANSITION`). + The root tree is wrapped in `MotionConfig reducedMotion="user"` (`src/main.tsx`), and `src/index.css` + carries the global `prefers-reduced-motion` neutralizer plus the shared hover/press interaction rules. - **Configurable Environment Parameters**: - `VITE_APPWRITE_ENDPOINT` – Base URL for the Appwrite API server. - `VITE_APPWRITE_PROJECT_ID` – The Appwrite project instance identifier. diff --git a/src/App.tsx b/src/App.tsx index bc71aeb..9f37d95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ import { SettingsView } from "./components/views/SettingsView"; import { TrendsView } from "./components/views/TrendsView"; import { buildDashboard, buildInsights } from "./lib/dashboard"; import { applyFilters, DEFAULT_FILTERS } from "./lib/filters"; +import { VIEW_TRANSITION } from "./lib/motion"; import { evaluateLimits, limitStatusMessage, @@ -659,7 +660,7 @@ function App() { initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} - transition={{ duration: 0.2 }} + transition={VIEW_TRANSITION} className="app-view" > {activeView === "overview" && ( diff --git a/src/components/BarcodeScannerModal.tsx b/src/components/BarcodeScannerModal.tsx index 7fc1312..fa3582a 100644 --- a/src/components/BarcodeScannerModal.tsx +++ b/src/components/BarcodeScannerModal.tsx @@ -26,6 +26,7 @@ import { } from "../lib/barcodeScanner"; import { listBarcodeCatalog, upsertCloudUserBarcodeMapping } from "../lib/appwriteBarcodes"; import { caffeinePerCan, currency, defaultPriceForSize, wholeNumber } from "../lib/metrics"; +import { MODAL_TRANSITION } from "../lib/motion"; import { loadUserBarcodeMappings, upsertUserBarcodeMapping, @@ -311,7 +312,7 @@ export function BarcodeScannerModal({ initial={{ opacity: 0, y: 18, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 14, scale: 0.98 }} - transition={{ duration: 0.22 }} + transition={MODAL_TRANSITION} >
diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index d6720fb..1119344 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,5 +1,6 @@ import { motion } from "framer-motion"; import { AlertTriangle, Loader2, Trash2 } from "lucide-react"; +import { MODAL_TRANSITION } from "../lib/motion"; import { Modal } from "./Modal"; type ConfirmDialogProps = { @@ -31,6 +32,7 @@ export function ConfirmDialog({ initial={{ opacity: 0, y: 16, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 12, scale: 0.98 }} + transition={MODAL_TRANSITION} >

{title} diff --git a/src/components/EntryModal.tsx b/src/components/EntryModal.tsx index e32279a..09ad778 100644 --- a/src/components/EntryModal.tsx +++ b/src/components/EntryModal.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEven import { MATERIAL_ACCENTS } from "../data/accents"; import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours"; import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics"; +import { MODAL_TRANSITION } from "../lib/motion"; import { Modal } from "./Modal"; import { Field } from "./ui"; import type { EntryDraft, Flavour, RedBullEntry } from "../types"; @@ -129,7 +130,7 @@ export function EntryModal({ initial={{ opacity: 0, y: 18, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 14, scale: 0.98 }} - transition={{ duration: 0.22 }} + transition={MODAL_TRANSITION} >
diff --git a/src/components/ImportPreviewModal.tsx b/src/components/ImportPreviewModal.tsx index e91b9c6..85bc05c 100644 --- a/src/components/ImportPreviewModal.tsx +++ b/src/components/ImportPreviewModal.tsx @@ -1,6 +1,7 @@ import { motion } from "framer-motion"; import { FileSpreadsheet, Loader2, X } from "lucide-react"; import { humanDateTime } from "../lib/metrics"; +import { MODAL_TRANSITION } from "../lib/motion"; import { MiniMetric } from "./MetricTiles"; import { Modal } from "./Modal"; import type { ImportPreview, ImportPreviewRow } from "../types"; @@ -30,6 +31,7 @@ export function ImportPreviewModal({ initial={{ opacity: 0, y: 18, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 14, scale: 0.98 }} + transition={MODAL_TRANSITION} >
diff --git a/src/components/MetricTiles.tsx b/src/components/MetricTiles.tsx index 69a3423..4e11047 100644 --- a/src/components/MetricTiles.tsx +++ b/src/components/MetricTiles.tsx @@ -1,5 +1,6 @@ import { motion } from "framer-motion"; import type { LucideIcon } from "lucide-react"; +import { TILE_TRANSITION } from "../lib/motion"; import { StatHint } from "./StatHint"; import { Card } from "./ui"; @@ -16,7 +17,7 @@ export function MetricTile({ icon: Icon, label, value, detail, accent }: MetricT
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 9942d4d..93b9f77 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,5 +1,6 @@ import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useRef, type ReactNode, type RefObject } from "react"; +import { MODAL_TRANSITION } from "../lib/motion"; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -116,6 +117,7 @@ export function Modal({ initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} + transition={MODAL_TRANSITION} role="dialog" aria-modal="true" aria-labelledby={labelledBy} diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx index 1a9b56b..367e6a8 100644 --- a/src/components/Toasts.tsx +++ b/src/components/Toasts.tsx @@ -1,6 +1,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react"; import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { TOAST_TRANSITION } from "../lib/motion"; import { ToastContext, type ToastInput, type ToastTone } from "../lib/toasts"; const TOAST_DURATION_MS = 4500; @@ -55,7 +56,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { initial={{ opacity: 0, y: -12, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -8, scale: 0.96 }} - transition={{ duration: 0.18 }} + transition={TOAST_TRANSITION} >