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:
@@ -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
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user