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
This commit is contained in:
nh9961
2026-07-19 20:45:47 +00:00
parent 3706021326
commit bb5750e928
13 changed files with 138 additions and 14 deletions
+2 -1
View File
@@ -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}
>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
+2
View File
@@ -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}
>
<h2 id="confirm-title" className="text-2xl font-semibold tracking-tight text-white">
{title}
+2 -1
View File
@@ -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}
>
<div className="mb-6 flex items-start justify-between gap-4">
<div>
+2
View File
@@ -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}
>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
+2 -1
View File
@@ -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
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22 }}
transition={TILE_TRANSITION}
>
<Card className="metric-tile">
<div className="flex items-start justify-between gap-3">
+2
View File
@@ -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}
+2 -1
View File
@@ -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}
>
<ToneIcon className="toast-icon" size={18} aria-hidden="true" />
<p className="toast-message">{toast.message}</p>
+2 -2
View File
@@ -36,7 +36,7 @@ export function TopBar({ activeView, busyAction, onAdd, onScan, className = "" }
<div className="top-action-row">
<button
className="secondary-button top-action-button justify-center active:scale-95"
className="secondary-button top-action-button justify-center"
type="button"
onClick={onScan}
disabled={Boolean(busyAction)}
@@ -46,7 +46,7 @@ export function TopBar({ activeView, busyAction, onAdd, onScan, className = "" }
<span className="top-action-label">Scan</span>
</button>
<button
className="primary-button top-action-button justify-center active:scale-95"
className="primary-button top-action-button justify-center"
type="button"
onClick={onAdd}
disabled={Boolean(busyAction)}