From d629ccf496b4457b26631622e0032553588d029c Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Wed, 30 Sep 2026 16:26:36 +0200 Subject: [PATCH] perf(frontend): drop the motion library for CSS keyframes and small FLIP/presence hooks --- frontend/bun.lock | 11 ---- frontend/package.json | 1 - .../features/landing/sections/hero/Hero.tsx | 48 +++++++---------- .../landing/sections/hero/ThemeStrip.tsx | 43 +++++----------- frontend/src/pages/public/auth/AuthScreen.tsx | 19 ++++--- frontend/src/shared/easter/BreakableBlock.tsx | 26 +++------- .../shared/easter/tests/breakable.test.tsx | 2 +- .../src/shared/layout/topbar/MobileMenu.tsx | 32 +++++------- frontend/src/shared/layout/topbar/NavPill.tsx | 30 ++++++----- frontend/src/shared/motion/RollText.tsx | 35 +++++++++++++ frontend/src/shared/motion/motion.css | 51 +++++++++++++++++++ frontend/src/shared/motion/usePresence.ts | 21 ++++++++ frontend/src/shared/motion/useSharedThumb.ts | 29 +++++++++++ 13 files changed, 213 insertions(+), 135 deletions(-) create mode 100644 frontend/src/shared/motion/RollText.tsx create mode 100644 frontend/src/shared/motion/motion.css create mode 100644 frontend/src/shared/motion/usePresence.ts create mode 100644 frontend/src/shared/motion/useSharedThumb.ts diff --git a/frontend/bun.lock b/frontend/bun.lock index 2ded820..ce9c2a3 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -12,7 +12,6 @@ "@tanstack/react-query": "^5.103.2", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", - "motion": "^13.4.3", "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.15", @@ -340,8 +339,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "framer-motion": ["framer-motion@13.4.3", "", { "dependencies": { "motion-dom": "^13.4.2", "motion-utils": "^13.3.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -476,12 +473,6 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "motion": ["motion@13.4.3", "", { "dependencies": { "framer-motion": "^13.4.3", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-Js4rk+VUxSRMKOFT4hOMyMKlxyF11ee9jomiLT3uuwMOU88EoSro0ZEbTYes4m457pqZQ2G3RsLdHQzoEDyBCQ=="], - - "motion-dom": ["motion-dom@13.4.2", "", { "dependencies": { "motion-utils": "^13.3.0" } }, "sha512-mY293Iyj4rOQo5sDxUwPYLOKiveLQrJoK1tKfG8V1H3MMGMfan3s2i7NBW18WnD39DD79ogyiL/1fmy22GLG5w=="], - - "motion-utils": ["motion-utils@13.3.0", "", {}, "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], @@ -572,8 +563,6 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], diff --git a/frontend/package.json b/frontend/package.json index 75fbe66..fff96ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,6 @@ "@tanstack/react-query": "^5.103.2", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", - "motion": "^13.4.3", "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.15", diff --git a/frontend/src/features/landing/sections/hero/Hero.tsx b/frontend/src/features/landing/sections/hero/Hero.tsx index 01508e9..d1d5c79 100644 --- a/frontend/src/features/landing/sections/hero/Hero.tsx +++ b/frontend/src/features/landing/sections/hero/Hero.tsx @@ -1,4 +1,3 @@ -import { MotionConfig, motion } from 'motion/react' import { lazy, Suspense, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router' @@ -9,16 +8,14 @@ import { useActiveTheme } from '@features/themes/useActiveTheme' import { ParticleField } from '../../effects/ParticleField' import { useInView } from '../../effects/useInView' import { useIdleReady } from '../../effects/useIdleReady' +import '@shared/motion/motion.css' import '@features/landing/styles/landing.css' // The backdrop is decorative and its first draw is heavy, so it loads and paints after the headline. const VoxelScene = lazy(() => import('../../effects/VoxelScene').then((m) => ({ default: m.VoxelScene }))) -const rise = (delay: number) => ({ - initial: { opacity: 0, y: 24 }, - animate: { opacity: 1, y: 0 }, - transition: { duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] as const }, -}) +/** Entrance: the `lv-rise` keyframes, staggered by `delay` seconds. */ +const rise = (delay: number) => ({ ['--d' as string]: `${delay}s` }) function DownloadIcon() { return ( @@ -43,7 +40,7 @@ export function Hero() { ] return ( - + <>
- - + +

{t('hero.headlineLead')} {t('hero.headlineAccent')} - - +

+

{t('hero.lead')} - - +

+
{t('hero.download')} @@ -80,34 +75,29 @@ export function Hero() { {t('hero.register')} - - +
+

{t('hero.downloadHint')} - - +

+
{facts.map((f) => (
{f.label}
{f.n}
))} - +
- +

- +
-
+ ) } diff --git a/frontend/src/features/landing/sections/hero/ThemeStrip.tsx b/frontend/src/features/landing/sections/hero/ThemeStrip.tsx index e3e9e85..ae6d5e5 100644 --- a/frontend/src/features/landing/sections/hero/ThemeStrip.tsx +++ b/frontend/src/features/landing/sections/hero/ThemeStrip.tsx @@ -1,8 +1,9 @@ -import { AnimatePresence, MotionConfig, motion } from 'motion/react' import { type MouseEvent, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ButtonLink } from '@shared/ui/Button' import { prefersReducedMotion } from '@shared/motion/reducedMotion' +import { RollText } from '@shared/motion/RollText' +import '@shared/motion/motion.css' import { themeToCssVars } from '@features/clickgui/themeVars' import { PRESETS } from '@features/themes/presets.generated' import { argbToCss, type ThemeColors } from '@features/themes/types' @@ -38,27 +39,22 @@ export function ThemeStrip() { } return ( - + <>
- - {wave ? ( - + {wave ? ( +
-
+ ) } diff --git a/frontend/src/pages/public/auth/AuthScreen.tsx b/frontend/src/pages/public/auth/AuthScreen.tsx index c77a439..f209493 100644 --- a/frontend/src/pages/public/auth/AuthScreen.tsx +++ b/frontend/src/pages/public/auth/AuthScreen.tsx @@ -1,10 +1,10 @@ -import { MotionConfig, motion } from 'motion/react' import { type ReactNode, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link, useLocation } from 'react-router' import { Backdrop } from '@features/clickgui/parts/Backdrop' import '@features/clickgui/parts/clickgui.css' import { themeToCssVars } from '@features/clickgui/themeVars' +import { useSharedThumb } from '@shared/motion/useSharedThumb' import { useActiveTheme } from '@features/themes/useActiveTheme' import './auth.css' @@ -19,6 +19,11 @@ function nextStep(from: string | undefined): 'link' | 'configs' | 'account' | 's if (from.startsWith('/showcase')) return 'showcase' } +function AuthThumb() { + const ref = useSharedThumb('lv-auth-thumb', 380) + return +} + function useClock(): string { const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) const [now, setNow] = useState(format) @@ -44,7 +49,7 @@ export function AuthScreen({ mode, title, hints, children }: { mode: Mode; title const step = nextStep(state?.from) return ( - + <>
-
+ ) } diff --git a/frontend/src/shared/easter/BreakableBlock.tsx b/frontend/src/shared/easter/BreakableBlock.tsx index 2d82558..ed04397 100644 --- a/frontend/src/shared/easter/BreakableBlock.tsx +++ b/frontend/src/shared/easter/BreakableBlock.tsx @@ -1,6 +1,6 @@ -import { AnimatePresence, motion, useReducedMotion } from 'motion/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import '@shared/motion/motion.css' import './block.css' /** Clicks needed before the block shatters: two crack stages then the burst. */ @@ -15,7 +15,6 @@ type Particle = { id: number; dx: string; dy: string } */ export function BreakableBlock() { const { t } = useTranslation('common') - const reduced = useReducedMotion() const [cracks, setCracks] = useState(0) const [particles, setParticles] = useState([]) const [cycle, setCycle] = useState(0) // bump to replay the drop after a burst @@ -44,28 +43,15 @@ export function BreakableBlock() { return (
- + - + - - {particles.map((p) => ( - - ))} - + {particles.map((p) => ( + + ))}
) } diff --git a/frontend/src/shared/easter/tests/breakable.test.tsx b/frontend/src/shared/easter/tests/breakable.test.tsx index 56c7936..3999ab4 100644 --- a/frontend/src/shared/easter/tests/breakable.test.tsx +++ b/frontend/src/shared/easter/tests/breakable.test.tsx @@ -4,7 +4,7 @@ import { expect, test, vi } from 'vitest' import { BreakableBlock } from '../BreakableBlock' import { renderApp } from '@test/render' -// motion's useReducedMotion reads matchMedia, which jsdom lacks. +// prefersReducedMotion reads matchMedia, which jsdom lacks. vi.stubGlobal('matchMedia', (query: string) => ({ matches: !query.includes('reduce'), media: query, diff --git a/frontend/src/shared/layout/topbar/MobileMenu.tsx b/frontend/src/shared/layout/topbar/MobileMenu.tsx index 0b48dd6..cdbdbe9 100644 --- a/frontend/src/shared/layout/topbar/MobileMenu.tsx +++ b/frontend/src/shared/layout/topbar/MobileMenu.tsx @@ -1,7 +1,7 @@ -import { AnimatePresence, MotionConfig, motion } from 'motion/react' import { type RefObject, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { NavLink } from 'react-router' +import { usePresence } from '@shared/motion/usePresence' import { LanguageSwitch } from '../../i18n/LanguageSwitch' import type { NavItem } from './NavPill' import { AccountSlot } from './parts' @@ -21,6 +21,7 @@ const FOCUSABLE = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1 export function MobileMenu({ id, items, open, onClose, toggleRef }: Props) { const { t } = useTranslation('common') const sheetRef = useRef(null) + const { mounted, state } = usePresence(open, 220) useEffect(() => { if (!open) return @@ -53,28 +54,20 @@ export function MobileMenu({ id, items, open, onClose, toggleRef }: Props) { }, [open, onClose, toggleRef]) return ( - - - {open && ( + <> + {mounted && ( <> -