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
+4
View File
@@ -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`. 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 - **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. `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**: - **Configurable Environment Parameters**:
- `VITE_APPWRITE_ENDPOINT` Base URL for the Appwrite API server. - `VITE_APPWRITE_ENDPOINT` Base URL for the Appwrite API server.
- `VITE_APPWRITE_PROJECT_ID` The Appwrite project instance identifier. - `VITE_APPWRITE_PROJECT_ID` The Appwrite project instance identifier.
+2 -1
View File
@@ -43,6 +43,7 @@ import { SettingsView } from "./components/views/SettingsView";
import { TrendsView } from "./components/views/TrendsView"; import { TrendsView } from "./components/views/TrendsView";
import { buildDashboard, buildInsights } from "./lib/dashboard"; import { buildDashboard, buildInsights } from "./lib/dashboard";
import { applyFilters, DEFAULT_FILTERS } from "./lib/filters"; import { applyFilters, DEFAULT_FILTERS } from "./lib/filters";
import { VIEW_TRANSITION } from "./lib/motion";
import { import {
evaluateLimits, evaluateLimits,
limitStatusMessage, limitStatusMessage,
@@ -659,7 +660,7 @@ function App() {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }} exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }} transition={VIEW_TRANSITION}
className="app-view" className="app-view"
> >
{activeView === "overview" && ( {activeView === "overview" && (
+2 -1
View File
@@ -26,6 +26,7 @@ import {
} from "../lib/barcodeScanner"; } from "../lib/barcodeScanner";
import { listBarcodeCatalog, upsertCloudUserBarcodeMapping } from "../lib/appwriteBarcodes"; import { listBarcodeCatalog, upsertCloudUserBarcodeMapping } from "../lib/appwriteBarcodes";
import { caffeinePerCan, currency, defaultPriceForSize, wholeNumber } from "../lib/metrics"; import { caffeinePerCan, currency, defaultPriceForSize, wholeNumber } from "../lib/metrics";
import { MODAL_TRANSITION } from "../lib/motion";
import { import {
loadUserBarcodeMappings, loadUserBarcodeMappings,
upsertUserBarcodeMapping, upsertUserBarcodeMapping,
@@ -311,7 +312,7 @@ export function BarcodeScannerModal({
initial={{ opacity: 0, y: 18, scale: 0.98 }} initial={{ opacity: 0, y: 18, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 14, scale: 0.98 }} 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 className="mb-5 flex items-start justify-between gap-4">
<div> <div>
+2
View File
@@ -1,5 +1,6 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react"; import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import { MODAL_TRANSITION } from "../lib/motion";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
type ConfirmDialogProps = { type ConfirmDialogProps = {
@@ -31,6 +32,7 @@ export function ConfirmDialog({
initial={{ opacity: 0, y: 16, scale: 0.98 }} initial={{ opacity: 0, y: 16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }} exit={{ opacity: 0, y: 12, scale: 0.98 }}
transition={MODAL_TRANSITION}
> >
<h2 id="confirm-title" className="text-2xl font-semibold tracking-tight text-white"> <h2 id="confirm-title" className="text-2xl font-semibold tracking-tight text-white">
{title} {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 { MATERIAL_ACCENTS } from "../data/accents";
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours"; import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours";
import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics"; import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics";
import { MODAL_TRANSITION } from "../lib/motion";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { Field } from "./ui"; import { Field } from "./ui";
import type { EntryDraft, Flavour, RedBullEntry } from "../types"; import type { EntryDraft, Flavour, RedBullEntry } from "../types";
@@ -129,7 +130,7 @@ export function EntryModal({
initial={{ opacity: 0, y: 18, scale: 0.98 }} initial={{ opacity: 0, y: 18, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 14, scale: 0.98 }} 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 className="mb-6 flex items-start justify-between gap-4">
<div> <div>
+2
View File
@@ -1,6 +1,7 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { FileSpreadsheet, Loader2, X } from "lucide-react"; import { FileSpreadsheet, Loader2, X } from "lucide-react";
import { humanDateTime } from "../lib/metrics"; import { humanDateTime } from "../lib/metrics";
import { MODAL_TRANSITION } from "../lib/motion";
import { MiniMetric } from "./MetricTiles"; import { MiniMetric } from "./MetricTiles";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import type { ImportPreview, ImportPreviewRow } from "../types"; import type { ImportPreview, ImportPreviewRow } from "../types";
@@ -30,6 +31,7 @@ export function ImportPreviewModal({
initial={{ opacity: 0, y: 18, scale: 0.98 }} initial={{ opacity: 0, y: 18, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 14, scale: 0.98 }} exit={{ opacity: 0, y: 14, scale: 0.98 }}
transition={MODAL_TRANSITION}
> >
<div className="mb-5 flex items-start justify-between gap-4"> <div className="mb-5 flex items-start justify-between gap-4">
<div> <div>
+2 -1
View File
@@ -1,5 +1,6 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { TILE_TRANSITION } from "../lib/motion";
import { StatHint } from "./StatHint"; import { StatHint } from "./StatHint";
import { Card } from "./ui"; import { Card } from "./ui";
@@ -16,7 +17,7 @@ export function MetricTile({ icon: Icon, label, value, detail, accent }: MetricT
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22 }} transition={TILE_TRANSITION}
> >
<Card className="metric-tile"> <Card className="metric-tile">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
+2
View File
@@ -1,5 +1,6 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, type ReactNode, type RefObject } from "react"; import { useEffect, useRef, type ReactNode, type RefObject } from "react";
import { MODAL_TRANSITION } from "../lib/motion";
const FOCUSABLE_SELECTOR = const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; '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 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={MODAL_TRANSITION}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={labelledBy} aria-labelledby={labelledBy}
+2 -1
View File
@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react"; import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState, type ReactNode } from "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"; import { ToastContext, type ToastInput, type ToastTone } from "../lib/toasts";
const TOAST_DURATION_MS = 4500; const TOAST_DURATION_MS = 4500;
@@ -55,7 +56,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
initial={{ opacity: 0, y: -12, scale: 0.96 }} initial={{ opacity: 0, y: -12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }} exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.18 }} transition={TOAST_TRANSITION}
> >
<ToneIcon className="toast-icon" size={18} aria-hidden="true" /> <ToneIcon className="toast-icon" size={18} aria-hidden="true" />
<p className="toast-message">{toast.message}</p> <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"> <div className="top-action-row">
<button <button
className="secondary-button top-action-button justify-center active:scale-95" className="secondary-button top-action-button justify-center"
type="button" type="button"
onClick={onScan} onClick={onScan}
disabled={Boolean(busyAction)} disabled={Boolean(busyAction)}
@@ -46,7 +46,7 @@ export function TopBar({ activeView, busyAction, onAdd, onScan, className = "" }
<span className="top-action-label">Scan</span> <span className="top-action-label">Scan</span>
</button> </button>
<button <button
className="primary-button top-action-button justify-center active:scale-95" className="primary-button top-action-button justify-center"
type="button" type="button"
onClick={onAdd} onClick={onAdd}
disabled={Boolean(busyAction)} disabled={Boolean(busyAction)}
+101 -4
View File
@@ -1734,10 +1734,6 @@ textarea:focus-visible {
background: color-mix(in srgb, var(--primary, #2563c7) 90%, #000000); background: color-mix(in srgb, var(--primary, #2563c7) 90%, #000000);
} }
.fab-add:active {
transform: scale(0.94);
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.fab-add { .fab-add {
display: none; display: none;
@@ -1975,3 +1971,104 @@ textarea:focus-visible {
color: var(--text, #202124); color: var(--text, #202124);
background: color-mix(in srgb, var(--text, #202124) 6%, transparent); background: color-mix(in srgb, var(--text, #202124) 6%, transparent);
} }
/* --- Motion & interaction language (A6) --- */
/* Neutralize CSS transitions/animations for reduced-motion users. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Hover feedback for interactive classes that lacked it (fine pointers only). */
@media (hover: hover) and (pointer: fine) {
.danger-button:hover {
background: color-mix(in srgb, var(--error-container, #fff3f1) 78%, var(--error, #ba1a1a));
}
.warning-button:hover {
background: color-mix(in srgb, var(--warning-container, #ffddb0) 78%, var(--warning, #8d5700));
}
.filter-bar-toggle:hover {
background: var(--surface-container-lowest, #ffffff);
}
.hero-search-button:hover {
background: var(--surface-container-low, #f7faff);
box-shadow: 0 4px 12px rgba(60, 64, 67, 0.14), 0 1px 2px rgba(60, 64, 67, 0.08);
}
.auth-mode-toggle button:hover {
color: var(--text, #202124);
}
.theme-tile:hover {
background: var(--surface-container-low, #f7faff);
border-color: color-mix(in srgb, var(--primary, #2563c7) 24%, var(--outline-variant, #d8e1ee));
}
}
/* Transform transitions so the shared press effect eases in and out. */
.nav-item {
transition: background 0.16s ease, transform 0.16s ease;
}
.quick-add-button {
transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.filter-bar-toggle,
.theme-tile {
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.hero-search-button {
transition: background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.auth-mode-toggle button,
.stat-hint-trigger,
.password-toggle,
.toast-dismiss {
transition: color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
/* Shared press effect; suppressed entirely under prefers-reduced-motion. */
@media (prefers-reduced-motion: no-preference) {
.primary-button:active,
.drawer-primary-action:active,
.secondary-button:active,
.danger-button:active,
.warning-button:active,
.excel-button:active,
.list-button:active,
.icon-button:active,
.suggestion-chip:active,
.account-pill:active,
.quick-add-button:active,
.filter-bar-toggle:active,
.theme-tile:active,
.hero-search-button:active,
.auth-mode-toggle button:active,
.nav-item:active,
.mobile-nav-item:active,
.toast-dismiss:active,
.stat-hint-trigger:active {
transform: scale(0.97);
}
.password-toggle:active {
transform: translateY(-50%) scale(0.97);
}
.fab-add:active {
transform: scale(0.94);
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { Transition } from "framer-motion";
// Standard ease-out curve shared by every animated surface (A6 motion language).
export const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
export const VIEW_TRANSITION: Transition = { duration: 0.2, ease: EASE_OUT };
export const MODAL_TRANSITION: Transition = { duration: 0.22, ease: EASE_OUT };
export const TOAST_TRANSITION: Transition = { duration: 0.18, ease: EASE_OUT };
export const TILE_TRANSITION: Transition = { duration: 0.22, ease: EASE_OUT };
+6 -3
View File
@@ -1,3 +1,4 @@
import { MotionConfig } from "framer-motion";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
@@ -6,8 +7,10 @@ import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<ToastProvider> <MotionConfig reducedMotion="user">
<App /> <ToastProvider>
</ToastProvider> <App />
</ToastProvider>
</MotionConfig>
</React.StrictMode>, </React.StrictMode>,
); );