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`.
- **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.
+2 -1
View File
@@ -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" && (
+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)}
+101 -4
View File
@@ -1734,10 +1734,6 @@ textarea:focus-visible {
background: color-mix(in srgb, var(--primary, #2563c7) 90%, #000000);
}
.fab-add:active {
transform: scale(0.94);
}
@media (min-width: 1024px) {
.fab-add {
display: none;
@@ -1975,3 +1971,104 @@ textarea:focus-visible {
color: var(--text, #202124);
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 ReactDOM from "react-dom/client";
import App from "./App";
@@ -6,8 +7,10 @@ import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ToastProvider>
<App />
</ToastProvider>
<MotionConfig reducedMotion="user">
<ToastProvider>
<App />
</ToastProvider>
</MotionConfig>
</React.StrictMode>,
);