From 710377e5cd48fa3f7bc4fc38d1d4839d05c64e5c Mon Sep 17 00:00:00 2001 From: "Mark Taratynov (Personal)" Date: Sat, 25 Apr 2026 22:27:22 +0200 Subject: [PATCH] =?UTF-8?q?perf:=20mobile=20performance=20optimizations=20?= =?UTF-8?q?=E2=80=94=20Lighthouse=2090/100/100/100?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove backdrop-filter on mobile nav, menu, and carousel cards - Disable ShimmerButton infinite spin animation on mobile/reduced-motion - AgentNetwork: flat transforms on mobile (no 3D matrix recalc on scroll) - Demo: skip 3D tilt on touch devices (pointer: fine check) - Hero: blurInUp by=word instead of by=character (14 GPU layers → 2) - TextAnimate: remove filter:blur from all variants (non-composited fix) - Add passive scroll listener in Nav - Remove unused Geist Mono font, add display:swap to Space Mono - Delete unused animated-beam.tsx - Fix --color-muted contrast: #6b6f82 → #94a3b8 (3.28:1 → 6.37:1) - Add modern browserslist targeting Chrome/Firefox/Safari/Edge 90+ Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 23 ++- app/components/AgentNetwork.tsx | 35 ++--- app/components/Demo.tsx | 20 +-- app/components/Hero.tsx | 2 +- app/components/Nav.tsx | 2 +- app/components/magicui/animated-beam.tsx | 163 ---------------------- app/components/magicui/shimmer-button.tsx | 4 +- app/components/magicui/text-animate.tsx | 18 +-- app/globals.css | 36 ++++- app/layout.tsx | 10 +- package.json | 6 + 11 files changed, 95 insertions(+), 224 deletions(-) delete mode 100644 app/components/magicui/animated-beam.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 2be1e8a..40d0c16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,14 @@ npm run build # production build npm run lint # ESLint ``` -No test suite is configured yet. +E2E tests run via Playwright against a live dev server: + +```bash +npm run test:e2e # run all Playwright tests (Chromium, WebKit, Mobile Safari) +npx playwright test e2e/ux.spec.ts # run a single spec +``` + +A QA audit script also runs via `npm run qa` (wraps Playwright + pa11y accessibility checks). ## Stack @@ -21,6 +28,9 @@ No test suite is configured yet. - **Tailwind CSS v4** — configured via `@tailwindcss/postcss` (not the classic `tailwind.config.js`). Customize design tokens in `app/globals.css` using `@theme { ... }` (no `inline` keyword). - **TypeScript** - **Firebase Admin SDK** — Firestore is used server-side via `app/lib/firebase-admin.ts`. Credentials come from `FIREBASE_PROJECT_ID`, `FIREBASE_CLIENT_EMAIL`, and `FIREBASE_PRIVATE_KEY` env vars. +- **framer-motion** and **gsap** — animation libraries available globally; `magicui/` primitives wrap these for section-level use. +- **Zod v4** — used for Server Action input validation (see `app/actions/newsletter.ts`). +- **Deployment** — Firebase App Hosting via `apphosting.yaml`. ## Architecture @@ -30,7 +40,7 @@ Single-page marketing/landing site for an AI athletic coaching app. `app/page.ts **Component layout:** - `app/components/` — all section components (one file per section, named after the section) -- `app/components/magicui/` — third-party-style animation primitives (`AnimatedBeam`, `Terminal`, `TextAnimate`) used by section components +- `app/components/magicui/` — animation primitives: `animated-beam.tsx`, `light-rays.tsx`, `shimmer-button.tsx`, `terminal.tsx`, `text-animate.tsx` - `app/lib/utils.ts` — exports `cn()` (clsx/tailwind-merge helper); import from `@/app/lib/utils` - `app/lib/firebase-admin.ts` — singleton Firebase Admin init, exports `db` and `admin` @@ -51,3 +61,12 @@ Single-page marketing/landing site for an AI athletic coaching app. `app/page.ts ### Components - Client components that need browser APIs (localStorage, matchMedia) use the `mounted` guard pattern (render `null` or hidden until `useEffect` fires) to avoid hydration mismatches — see `ThemeToggle.tsx`. - `AgentNetwork.tsx` uses `AnimatedBeam` from `magicui/` — beams require both a `containerRef` on the wrapping div and `fromRef`/`toRef` on node divs. + +## Git hooks + +- **pre-commit**: runs `npm run lint` (ESLint must pass before committing) +- **pre-push**: runs `npm run qa` (full QA audit including Playwright + pa11y) + +## Server Action conventions + +`subscribeToNewsletter` in `app/actions/newsletter.ts` uses the `useActionState` pattern — it accepts `(prevState, formData)` and returns `{ success?, error?, message? } | null`. New Server Actions should follow this same shape. Honeypot field name is `website`. diff --git a/app/components/AgentNetwork.tsx b/app/components/AgentNetwork.tsx index 91c73a9..1a904ba 100644 --- a/app/components/AgentNetwork.tsx +++ b/app/components/AgentNetwork.tsx @@ -30,12 +30,14 @@ const scenarios = [ }, ]; -// Diagonal resting tilt — every card sits at this angle when active -const REST = { rotationX: 7, rotationY: -18, scale: 1, y: 0, opacity: 1 }; -// Cards wait far above viewport — hidden until their animation starts -const WAIT = { rotationX: 18, rotationY: -6, scale: 1.04, y: -800, opacity: 0 }; -// Active card exits downward and tilts deeper -const EXIT = { rotationX: 3, rotationY: -32, scale: 0.85, y: 200, opacity: 0 }; +const REST_3D = { rotationX: 7, rotationY: -18, scale: 1, y: 0, opacity: 1 }; +const WAIT_3D = { rotationX: 18, rotationY: -6, scale: 1.04, y: -800, opacity: 0 }; +const EXIT_3D = { rotationX: 3, rotationY: -32, scale: 0.85, y: 200, opacity: 0 }; + +// Flat states for mobile — no 3D matrix recalc on every scroll tick +const REST_FLAT = { rotationX: 0, rotationY: 0, scale: 1, y: 0, opacity: 1 }; +const WAIT_FLAT = { rotationX: 0, rotationY: 0, scale: 1, y: -60, opacity: 0 }; +const EXIT_FLAT = { rotationX: 0, rotationY: 0, scale: 0.95, y: 60, opacity: 0 }; const SCROLL_PER_CARD = 720; @@ -48,12 +50,16 @@ export default function AgentNetwork() { const dotsEl = dotsRef.current; if (!section || !dotsEl) return; + const isMobile = window.matchMedia('(max-width: 768px)').matches; + const REST = isMobile ? REST_FLAT : REST_3D; + const WAIT = isMobile ? WAIT_FLAT : WAIT_3D; + const EXIT = isMobile ? EXIT_FLAT : EXIT_3D; + const ctx = gsap.context(() => { const cards = gsap.utils.toArray(".an-card", section); const dots = gsap.utils.toArray(".an-dot", dotsEl); const n = cards.length; - // Initial state gsap.set(cards[0], { ...REST, zIndex: 10 }); cards.slice(1).forEach((card) => { gsap.set(card, { ...WAIT, zIndex: 1 }); @@ -79,15 +85,10 @@ export default function AgentNetwork() { }); cards.forEach((card, i) => { - // Exit animation for current card - if (i < n - 1) { - tl.to(card, { ...EXIT, duration: 1 }, i); - } - - // Entrance animation for next card + if (i < n - 1) tl.to(card, { ...EXIT, duration: 1 }, i); if (i > 0) { - tl.set(card, { zIndex: 20 }, i - 1) // Bring to front - .to(card, { ...REST, duration: 1 }, i - 1); // Animate from WAIT (opacity 0) to REST (opacity 1) + tl.set(card, { zIndex: 20 }, i - 1) + .to(card, { ...REST, duration: 1 }, i - 1); } }); }, section); @@ -128,8 +129,8 @@ export default function AgentNetwork() { {/* Left: Card stack */}
{scenarios.map((card, i) => (
(null) - - // 3D Tilt values + const hasPointer = useRef(false) + + useEffect(() => { + hasPointer.current = window.matchMedia('(pointer: fine)').matches + }, []) + const x = useMotionValue(0) const y = useMotionValue(0) @@ -18,14 +22,10 @@ export default function Demo() { const rotateY = useTransform(mouseXSpring, [-0.5, 0.5], ["-10deg", "10deg"]) const handleMouseMove = (e: React.MouseEvent) => { - if (!cardRef.current) return + if (!hasPointer.current || !cardRef.current) return const rect = cardRef.current.getBoundingClientRect() - const width = rect.width - const height = rect.height - const mouseX = e.clientX - rect.left - const mouseY = e.clientY - rect.top - const xPct = mouseX / width - 0.5 - const yPct = mouseY / height - 0.5 + const xPct = (e.clientX - rect.left) / rect.width - 0.5 + const yPct = (e.clientY - rect.top) / rect.height - 0.5 x.set(xPct) y.set(yPct) } diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 4e986a1..c0a0a7d 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -35,7 +35,7 @@ export default function Hero() {

- + Train smarter. { const handleScroll = () => setScrolled(window.scrollY > 20) - window.addEventListener('scroll', handleScroll) + window.addEventListener('scroll', handleScroll, { passive: true }) return () => window.removeEventListener('scroll', handleScroll) }, []) diff --git a/app/components/magicui/animated-beam.tsx b/app/components/magicui/animated-beam.tsx deleted file mode 100644 index 943af80..0000000 --- a/app/components/magicui/animated-beam.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use client" - -import { useEffect, useId, useState, type RefObject } from "react" -import { motion } from "framer-motion" -import { cn } from "@/app/lib/utils" - -export interface AnimatedBeamProps { - className?: string - containerRef: RefObject - fromRef: RefObject - toRef: RefObject - curvature?: number - reverse?: boolean - pathColor?: string - pathWidth?: number - pathOpacity?: number - gradientStartColor?: string - gradientStopColor?: string - delay?: number - duration?: number - repeat?: number - repeatDelay?: number - startXOffset?: number; - startYOffset?: number; - endXOffset?: number; - endYOffset?: number; -} - -export const AnimatedBeam: React.FC = ({ - className, - containerRef, - fromRef, - toRef, - curvature = 0, - reverse = false, - duration = 5, - delay = 0, - pathColor = "gray", - pathWidth = 2, - pathOpacity = 0.2, - gradientStartColor = "#ffaa40", - gradientStopColor = "#9c40ff", - repeat = Infinity, - repeatDelay = 0, - startXOffset = 0, - startYOffset = 0, - endXOffset = 0, - endYOffset = 0, -}) => { - const id = useId() - const [pathD, setPathD] = useState("") - const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 }) - - const gradientCoordinates = reverse - ? { - x1: ["90%", "-10%"], - x2: ["100%", "0%"], - y1: ["0%", "0%"], - y2: ["0%", "0%"], - } - : { - x1: ["10%", "110%"], - x2: ["0%", "100%"], - y1: ["0%", "0%"], - y2: ["0%", "0%"], - } - - useEffect(() => { - const updatePath = () => { - if (containerRef.current && fromRef.current && toRef.current) { - const containerRect = containerRef.current.getBoundingClientRect() - const rectA = fromRef.current.getBoundingClientRect() - const rectB = toRef.current.getBoundingClientRect() - - const svgWidth = containerRect.width - const svgHeight = containerRect.height - setSvgDimensions({ width: svgWidth, height: svgHeight }) - - const startX = - rectA.left - containerRect.left + rectA.width / 2 + (startXOffset || 0) - const startY = - rectA.top - containerRect.top + rectA.height / 2 + (startYOffset || 0) - const endX = - rectB.left - containerRect.left + rectB.width / 2 + (endXOffset || 0) - const endY = - rectB.top - containerRect.top + rectB.height / 2 + (endYOffset || 0) - - const controlY = startY - curvature - const d = `M ${startX},${startY} Q ${ - (startX + endX) / 2 - },${controlY} ${endX},${endY}` - setPathD(d) - } - } - - const resizeObserver = new ResizeObserver(() => updatePath()) - if (containerRef.current) resizeObserver.observe(containerRef.current) - - updatePath() - return () => resizeObserver.disconnect() - }, [ - containerRef, - fromRef, - toRef, - curvature, - startXOffset, - startYOffset, - endXOffset, - endYOffset, - ]) - - return ( - - - - - - - - - - - - - ) -} diff --git a/app/components/magicui/shimmer-button.tsx b/app/components/magicui/shimmer-button.tsx index 2367188..8fb0d2f 100644 --- a/app/components/magicui/shimmer-button.tsx +++ b/app/components/magicui/shimmer-button.tsx @@ -43,8 +43,8 @@ export const ShimmerButton = React.forwardRef< {...props} > {/* 1. The Shimmer Layer (Spinning Light) */} -
diff --git a/package.json b/package.json index c2e1e53..72d9631 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,12 @@ "tailwind-merge": "^3.5.0", "zod": "^4.3.6" }, + "browserslist": [ + "chrome >= 90", + "firefox >= 90", + "safari >= 14", + "edge >= 90" + ], "devDependencies": { "@axe-core/playwright": "^4.11.2", "@playwright/test": "^1.59.1",