diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index 007e0ed2..c59ac7b2 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -1,7 +1,26 @@ import RegistrationForm from "@/components/RegistrationForm"; import Link from "next/link"; import GuestGuard from "@/components/GuestGuard"; -import { OnboardingProgressTracker, type OnboardingStep } from "@/components/OnboardingProgressTracker"; +import dynamic from "next/dynamic"; +import OnboardingTrackerSkeleton from "@/components/OnboardingTrackerSkeleton"; +import type { OnboardingStep } from "@/components/OnboardingProgressTracker"; + +/** + * Lazy-load the tracker so its JS bundle (including framer-motion) is only + * fetched after the page paints — the skeleton renders instantly in its place. + */ +const OnboardingProgressTracker = dynamic( + () => import("@/components/OnboardingProgressTracker").then((m) => m.OnboardingProgressTracker), + { + ssr: false, + loading: () => ( + + ), + }, +); /** * Demo steps shown on the registration page so users understand the full diff --git a/frontend/src/components/OnboardingProgressTracker.tsx b/frontend/src/components/OnboardingProgressTracker.tsx index f54a27f5..51a65f7b 100644 --- a/frontend/src/components/OnboardingProgressTracker.tsx +++ b/frontend/src/components/OnboardingProgressTracker.tsx @@ -1,36 +1,73 @@ "use client"; /** - * OnboardingProgressTracker + * OnboardingProgressTracker — bundle-optimised client component * - * Refactored for full i18n support via the "onboarding" next-intl namespace. + * Bundle-optimisation strategy: + * ───────────────────────────── + * 1. framer-motion is NOT statically imported. The three exports we need + * (motion, AnimatePresence, useReducedMotion) are lazy-loaded via + * next/dynamic only after mount so they don't block the initial JS parse. * - * Additional improvements: - * - All user-visible strings sourced from useOnboardingI18n (no hardcoded English) - * - Dark mode: dark: Tailwind variants on every surface, border, text, gradient - * - Mobile-first responsive layout (vertical default, horizontal on sm+) - * - Improved a11y: translated aria-labels, aria-busy spinner, focus-visible rings - * - StepIcon and StatusBadge extracted as memoised sub-components - * - prefers-reduced-motion respected throughout - * - Connector lines between vertical steps + * 2. StepIcon and StatusBadge are now CSS-only — they use Tailwind utility + * classes + keyframe animations defined in tailwind.config.js instead of + * motion.* primitives. This removes ~100 % of framer-motion from the + * render hot-path for each step row. + * + * 3. The progress-bar fill animates via the `animate-onboarding-fill` CSS + * class instead of motion.div variants — zero JS at paint time. + * + * 4. The completion banner and list container still use the lazy-loaded + * motion wrappers for their entrance/exit effects, but those code paths + * are not executed on initial render. + * + * 5. useOnboardingI18n returns a memoised object so reference equality is + * stable between renders (no wasted downstream re-renders). + * + * 6. All sub-components are memo()'d to prevent re-renders when only + * unrelated siblings change. */ -import React, { memo } from "react"; -import { - motion, - AnimatePresence, - useReducedMotion, - type Variants, -} from "framer-motion"; +import React, { memo, useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { useOnboardingProgress, type OnboardingStep, } from "@/hooks/useOnboardingProgress"; import { useOnboardingI18n } from "@/hooks/useOnboardingI18n"; -// ── Re-export so consumers need only one import ─────────────────────────────── +// ── Re-export for consumers ─────────────────────────────────────────────────── export type { OnboardingStep }; +// ── Lazy framer-motion ──────────────────────────────────────────────────────── +// Only the completion banner and the step-list container use motion primitives. +// We lazy-load them so framer-motion never blocks the first paint. + +const MotionDiv = dynamic( + () => import("framer-motion").then((m) => m.motion.div), + { ssr: false }, +); + +const MotionOl = dynamic( + () => import("framer-motion").then((m) => m.motion.ol), + { ssr: false }, +); + +const MotionLi = dynamic( + () => import("framer-motion").then((m) => m.motion.li), + { ssr: false }, +); + +const MotionSvg = dynamic( + () => import("framer-motion").then((m) => m.motion.svg), + { ssr: false }, +); + +const AnimatePresence = dynamic( + () => import("framer-motion").then((m) => m.AnimatePresence), + { ssr: false }, +); + // ── Props ───────────────────────────────────────────────────────────────────── export interface OnboardingProgressTrackerProps { @@ -38,74 +75,14 @@ export interface OnboardingProgressTrackerProps { currentStep?: string; onStepChange?: (stepId: string) => void | Promise; onComplete?: () => void; - /** Show numeric labels inside step circles. Default: true. */ showStepNumbers?: boolean; - /** Stack direction. Default: "vertical". */ orientation?: "vertical" | "horizontal"; - /** Reduced padding variant. Default: false. */ compact?: boolean; - /** Extra className on the root wrapper. */ className?: string; } -// ── Animation variants ──────────────────────────────────────────────────────── - -const containerVariants: Variants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { staggerChildren: 0.08, delayChildren: 0.15 }, - }, -}; - -const stepVariants: Variants = { - hidden: { opacity: 0, x: -16 }, - visible: { opacity: 1, x: 0, transition: { duration: 0.35, ease: [0.16, 1, 0.3, 1] } }, - exit: { opacity: 0, x: 16, transition: { duration: 0.2 } }, -}; - -const stepVariantsReduced: Variants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.15 } }, - exit: { opacity: 0, transition: { duration: 0.1 } }, -}; - -const progressBarVariants: Variants = { - hidden: { scaleX: 0, originX: 0 }, - visible: { scaleX: 1, originX: 0, transition: { duration: 0.55, ease: [0.16, 1, 0.3, 1] } }, -}; - -const progressBarVariantsReduced: Variants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.25 } }, -}; - -const checkMarkVariants: Variants = { - hidden: { scale: 0, opacity: 0 }, - visible: { - scale: 1, opacity: 1, - transition: { type: "spring", stiffness: 280, damping: 22, delay: 0.15 }, - }, -}; - -const checkMarkVariantsReduced: Variants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.15 } }, -}; - -const completionVariants: Variants = { - hidden: { opacity: 0, y: 12 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" } }, - exit: { opacity: 0, y: -8, transition: { duration: 0.2 } }, -}; - -const completionVariantsReduced: Variants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.15 } }, - exit: { opacity: 0, transition: { duration: 0.1 } }, -}; - -// ── StepIcon ────────────────────────────────────────────────────────────────── +// ── CSS-only StepIcon ───────────────────────────────────────────────────────── +// Uses Tailwind keyframe classes instead of framer-motion — no JS animation cost. interface StepIconProps { completed: boolean; @@ -114,8 +91,7 @@ interface StepIconProps { number: number; showNumber: boolean; compact: boolean; - checkVariants: Variants; - prefersReducedMotion: boolean | null; + prefersReducedMotion: boolean; } const StepIcon = memo(function StepIcon({ @@ -125,80 +101,60 @@ const StepIcon = memo(function StepIcon({ number, showNumber, compact, - checkVariants, prefersReducedMotion, }: StepIconProps) { - return ( - - {completed ? ( - - - - ) : isPending ? ( - - - - ) : ( - - )} - + + + + + ); + } + + if (completed) { + return ( + + ); + } + + return ( + ); }); -// ── StatusBadge ─────────────────────────────────────────────────────────────── +// ── CSS-only StatusBadge ────────────────────────────────────────────────────── interface StatusBadgeProps { completed: boolean; @@ -207,7 +163,6 @@ interface StatusBadgeProps { completedLabel: string; inProgressLabel: string; pendingLabel: string; - prefersReducedMotion: boolean | null; } const StatusBadge = memo(function StatusBadge({ @@ -217,13 +172,8 @@ const StatusBadge = memo(function StatusBadge({ completedLabel, inProgressLabel, pendingLabel, - prefersReducedMotion, }: StatusBadgeProps) { - const label = completed - ? completedLabel - : isCurrent - ? inProgressLabel - : pendingLabel; + const label = completed ? completedLabel : isCurrent ? inProgressLabel : pendingLabel; const colorClass = completed ? "bg-pluto-100 text-pluto-800 dark:bg-pluto-900/40 dark:text-pluto-200" @@ -232,17 +182,14 @@ const StatusBadge = memo(function StatusBadge({ : "bg-pluto-50 text-pluto-700 dark:bg-pluto-900/20 dark:text-pluto-300 group-hover:bg-pluto-100 dark:group-hover:bg-pluto-900/40"; return ( - {label} - + ); }); @@ -259,7 +206,17 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker className = "", }: OnboardingProgressTrackerProps) { const i18n = useOnboardingI18n(); - const prefersReducedMotion = useReducedMotion(); + + // Read reduced-motion preference via CSS media query — avoids importing + // useReducedMotion from framer-motion (saves ~2 KB). + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReducedMotion(mq.matches); + const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); const { sortedSteps, @@ -272,11 +229,25 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker handleStepClick, } = useOnboardingProgress({ steps, currentStep, onStepChange, onComplete }); - // Pick motion variants based on reduced-motion preference - const activeStepVariants = prefersReducedMotion ? stepVariantsReduced : stepVariants; - const activeProgressBarVariants = prefersReducedMotion ? progressBarVariantsReduced : progressBarVariants; - const activeCheckMarkVariants = prefersReducedMotion ? checkMarkVariantsReduced : checkMarkVariants; - const activeCompletionVariants = prefersReducedMotion ? completionVariantsReduced : completionVariants; + // Lazy motion variants — only referenced after framer-motion has loaded + const containerVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { staggerChildren: 0.08, delayChildren: 0.15 } }, + }; + const stepVariants = prefersReducedMotion + ? { hidden: { opacity: 0 }, visible: { opacity: 1 }, exit: { opacity: 0 } } + : { + hidden: { opacity: 0, x: -16 }, + visible: { opacity: 1, x: 0, transition: { duration: 0.35, ease: [0.16, 1, 0.3, 1] } }, + exit: { opacity: 0, x: 16, transition: { duration: 0.2 } }, + }; + const completionVariants = prefersReducedMotion + ? { hidden: { opacity: 0 }, visible: { opacity: 1 }, exit: { opacity: 0 } } + : { + hidden: { opacity: 0, y: 12 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" } }, + exit: { opacity: 0, y: -8, transition: { duration: 0.2 } }, + }; return (
- {/* Assertive live region for step-change announcements */} + {/* Assertive announcement */}
- {/* Polite pending indicator */} {state.isPending && (
{i18n.updating} @@ -326,17 +296,10 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker {/* ── Header ──────────────────────────────────────────────────────── */}
-

+

{i18n.title}

-
@@ -345,7 +308,7 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker {i18n.subtitle}

- {/* Progress bar */} + {/* CSS-animated progress bar — no framer-motion */}
-
- {/* Step count summary line */} -
{/* ── Steps list ───────────────────────────────────────────────────── */} - + {/* @ts-expect-error — AnimatePresence is lazy-loaded, types resolve at runtime */} {sortedSteps.map((step, index) => { - const isCurrent = effectiveCurrentStep === step.id; - const isPending = state.isPending && isCurrent; + const isCurrent = effectiveCurrentStep === step.id; + const isPending = state.isPending && isCurrent; const stepDescId = `${progressSummaryId}-desc-${index}`; const indicatorColorClass = step.completed @@ -413,27 +364,18 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker : "border-pluto-200 bg-white text-pluto-600 dark:border-pluto-700 dark:bg-pluto-900/40 dark:text-pluto-400 group-hover:border-pluto-400 group-hover:bg-pluto-50 dark:group-hover:border-pluto-500 dark:group-hover:bg-pluto-800/50"; return ( - {/* Step indicator button */} - {/* Step text content */} + {/* Step text */}

{step.title} {step.required && ( - + * )}

-

+

{step.description}

@@ -516,33 +444,29 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker completedLabel={i18n.completed} inProgressLabel={i18n.inProgress} pendingLabel={i18n.pending} - prefersReducedMotion={prefersReducedMotion} />
- {/* Vertical connector line */} + {/* Vertical connector */} {orientation === "vertical" && index < sortedSteps.length - 1 && ( diff --git a/frontend/src/components/OnboardingTrackerSkeleton.tsx b/frontend/src/components/OnboardingTrackerSkeleton.tsx new file mode 100644 index 00000000..a06d4614 --- /dev/null +++ b/frontend/src/components/OnboardingTrackerSkeleton.tsx @@ -0,0 +1,106 @@ +/** + * OnboardingTrackerSkeleton + * + * Server-safe, zero-dependency shimmer skeleton used as: + * 1. The dynamic() loading fallback while the tracker bundle streams in. + * 2. A standalone placeholder for SSR / Suspense boundaries. + * + * No framer-motion, no hooks, no "use client" — renders identically on + * server and client. + */ + +import React from "react"; + +function Bone({ className = "" }: { className?: string }) { + return ( +