From 16d06d3275f1385963fa5321177201f84a527027 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 20 Feb 2026 11:53:42 +0100 Subject: [PATCH] perf: deep react code splitting, next-intl payload scoping, and SVG hardware acceleration for PageSpeed 100 --- app/[locale]/layout.tsx | 13 +- app/[locale]/page.tsx | 5 +- components/Header.tsx | 648 +++++++------- components/Lightbox.tsx | 218 ++--- components/Scribble.tsx | 82 +- components/analytics/AnalyticsShell.tsx | 16 +- components/home/Hero.tsx | 206 ++--- components/home/HeroIllustration.tsx | 71 +- components/record-mode/PlaybackCursor.tsx | 118 +-- components/record-mode/RecordModeOverlay.tsx | 828 +++++++++--------- .../glitchtip-error-reporting-service.ts | 74 +- next.config.mjs | 3 + scripts/replace-motion.cjs | 21 + sentry.client.config.ts | 23 +- styles/globals.css | 29 +- 15 files changed, 1234 insertions(+), 1121 deletions(-) create mode 100644 scripts/replace-motion.cjs diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 44baf9cd..76e3791d 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -72,7 +72,7 @@ export default async function Layout(props: { setRequestLocale(safeLocale); - let messages = {}; + let messages: Record = {}; try { messages = await getMessages(); } catch (error) { @@ -80,6 +80,15 @@ export default async function Layout(props: { messages = {}; } + // Pick only the namespaces required by client components to reduce the hydration payload size + const clientKeys = ['Footer', 'Navigation', 'Contact', 'Products', 'Team', 'Home']; + const clientMessages: Record = {}; + for (const key of clientKeys) { + if (messages[key]) { + clientMessages[key] = messages[key]; + } + } + const { getServerAppServices } = await import('@/lib/services/create-services.server'); const serverServices = getServerAppServices(); @@ -118,7 +127,7 @@ export default async function Layout(props: { - + diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 67b6864c..a0e49ae4 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,11 +1,12 @@ import Hero from '@/components/home/Hero'; import JsonLd from '@/components/JsonLd'; import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema'; -import ProductCategories from '@/components/home/ProductCategories'; -import WhatWeDo from '@/components/home/WhatWeDo'; import dynamic from 'next/dynamic'; import Reveal from '@/components/Reveal'; +const ProductCategories = dynamic(() => import('@/components/home/ProductCategories')); +const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo')); + const RecentPosts = dynamic(() => import('@/components/home/RecentPosts')); const Experience = dynamic(() => import('@/components/home/Experience')); const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs')); diff --git a/components/Header.tsx b/components/Header.tsx index 91fac1a5..c0a6e913 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import Image from 'next/image'; -import { motion } from 'framer-motion'; +import { m, LazyMotion, domAnimation } from 'framer-motion'; import { useTranslations } from 'next-intl'; import { usePathname } from 'next/navigation'; import { Button } from './ui'; @@ -114,55 +114,264 @@ export default function Header() { return ( <> - -
- - - trackEvent(AnalyticsEvents.BUTTON_CLICK, { - target: 'home_logo', - location: 'header', - }) - } + + +
+ - {t('home')} - - + + trackEvent(AnalyticsEvents.BUTTON_CLICK, { + target: 'home_logo', + location: 'header', + }) + } + > + {t('home')} + + - + + {menuItems.map((item, _idx) => ( + + { + setIsMobileMenuOpen(false); + trackEvent(AnalyticsEvents.LINK_CLICK, { + label: item.label, + href: item.href, + location: 'header_nav', + }); + }} + className={cn( + textColorClass, + 'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5', + )} + > + {item.label} + + + + ))} + + + + + + + trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { + type: 'language', + from: currentLocale, + to: 'en', + location: 'header', + }) + } + className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`} + > + EN + + + + + + trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { + type: 'language', + from: currentLocale, + to: 'de', + location: 'header', + }) + } + className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`} + > + DE + + + + + + + + + + {/* Mobile Menu Button */} + { + const newState = !isMobileMenuOpen; + setIsMobileMenuOpen(newState); + trackEvent(AnalyticsEvents.BUTTON_CLICK, { + type: 'mobile_menu', + action: newState ? 'open' : 'close', + }); + }} + > + + {isMobileMenuOpen ? ( + + ) : ( + + )} + + + +
+ + {/* Mobile Menu Overlay */} + - - {/* Mobile Menu Overlay */} - - + {t('home')} + + + +
+ + ); } diff --git a/components/Lightbox.tsx b/components/Lightbox.tsx index 2adf3fc9..15757019 100644 --- a/components/Lightbox.tsx +++ b/components/Lightbox.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import Image from 'next/image'; import { createPortal } from 'react-dom'; -import { motion, AnimatePresence } from 'framer-motion'; +import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; interface LightboxProps { @@ -139,118 +139,120 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh if (!mounted) return null; return createPortal( - - {isOpen && ( -
- - - + + {isOpen && ( +
-
- × -
- + - - - ‹ - - - - - - › - - - - -
-
- - - {`Gallery - - - - {/* Technical Detail: Subtle grid overlay to reinforce industrial precision */} -
- - {/* Premium Reflection: Subtle gradient to give material feel */} -
+ +
+ ×
+
- -
-
- {currentIndex + 1} / {images.length} + + + ‹ + + + + + + › + + + + +
+
+ + + {`Gallery + + + + {/* Technical Detail: Subtle grid overlay to reinforce industrial precision */} +
+ + {/* Premium Reflection: Subtle gradient to give material feel */} +
-
- -
- -
- )} - , + + +
+
+ {currentIndex + 1} / {images.length} +
+
+ +
+ +
+ )} + + , document.body, ); } diff --git a/components/Scribble.tsx b/components/Scribble.tsx index 03676045..e46d08e5 100644 --- a/components/Scribble.tsx +++ b/components/Scribble.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; -import { motion, Variants } from 'framer-motion'; +import { m, LazyMotion, domAnimation, Variants } from 'framer-motion'; import { cn } from '@/components/ui'; interface ScribbleProps { @@ -25,49 +25,53 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri if (variant === 'circle') { return ( - + + + ); } if (variant === 'underline') { return ( - + + + ); } diff --git a/components/analytics/AnalyticsShell.tsx b/components/analytics/AnalyticsShell.tsx index bda24abe..94a93cd4 100644 --- a/components/analytics/AnalyticsShell.tsx +++ b/components/analytics/AnalyticsShell.tsx @@ -1,7 +1,7 @@ 'use client'; import dynamic from 'next/dynamic'; -import { Suspense } from 'react'; +import { Suspense, useEffect, useState } from 'react'; const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), { ssr: false, @@ -11,6 +11,20 @@ const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), }); export default function AnalyticsShell() { + const [shouldLoad, setShouldLoad] = useState(false); + + useEffect(() => { + // Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs + if (typeof window !== 'undefined' && 'requestIdleCallback' in window) { + window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 }); + } else { + const timer = setTimeout(() => setShouldLoad(true), 2500); + return () => clearTimeout(timer); + } + }, []); + + if (!shouldLoad) return null; + return ( diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index cd13c31f..e8d5b5fe 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -2,7 +2,7 @@ import Scribble from '@/components/Scribble'; import { Button, Container, Heading, Section } from '@/components/ui'; -import { motion } from 'framer-motion'; +import { m, LazyMotion, domAnimation } from 'framer-motion'; import { useTranslations, useLocale } from 'next-intl'; import dynamic from 'next/dynamic'; import { useAnalytics } from '../analytics/useAnalytics'; @@ -16,111 +16,113 @@ export default function Hero() { return (
- - - - - {t.rich('title', { - green: (chunks) => ( - - - {chunks} - - - - - - ), - })} - - - -

- {t('subtitle')} -

-
- + + - - - - - - - -
-
+ {t.rich('title', { + green: (chunks) => ( + + + {chunks} + + + + + + ), + })} + + + +

+ {t('subtitle')} +

+
+ + + + + + + + + + - - - + + + - -
- -
-
+ +
+ +
+
+
); } diff --git a/components/home/HeroIllustration.tsx b/components/home/HeroIllustration.tsx index 1fe3b3e6..987a59e2 100644 --- a/components/home/HeroIllustration.tsx +++ b/components/home/HeroIllustration.tsx @@ -232,16 +232,12 @@ export default function HeroIllustration() { stroke="url(#energy-pulse)" strokeWidth="3" strokeLinecap="round" - strokeDasharray={`${length * 0.2} ${length * 0.8}`} - > - - + style={{ + strokeDasharray: `${length * 0.2} ${length * 0.8}`, + strokeDashoffset: length, + animation: `flow ${1.5 + (i % 3) * 0.5}s linear infinite`, + }} + /> ); })} @@ -267,14 +263,13 @@ export default function HeroIllustration() { strokeWidth="1" strokeOpacity="0.3" /> - - - + ); })} @@ -294,28 +289,26 @@ export default function HeroIllustration() { strokeOpacity="0.3" /> - {[0, 120, 240].map((angle, j) => ( - - + {[0, 120, 240].map((angle, j) => ( + - - ))} + ))} + ); diff --git a/components/record-mode/PlaybackCursor.tsx b/components/record-mode/PlaybackCursor.tsx index 7e1fd8ca..b3b21d60 100644 --- a/components/record-mode/PlaybackCursor.tsx +++ b/components/record-mode/PlaybackCursor.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useState } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { useRecordMode } from './RecordModeContext'; export function PlaybackCursor() { @@ -24,67 +24,69 @@ export function PlaybackCursor() { if (!isPlaying) return null; return ( - - - {isClicking && ( - - )} - + + + + {isClicking && ( + + )} + - {/* Outer Pulse Ring */} -
- - {/* Visual Cursor */} -
- {/* Soft Glow */} + {/* Outer Pulse Ring */}
- {/* Pointer Arrow */} - - + {/* Soft Glow */} +
- -
- + + {/* Pointer Arrow */} + + + +
+ + ); } diff --git a/components/record-mode/RecordModeOverlay.tsx b/components/record-mode/RecordModeOverlay.tsx index 18ebeaba..619d680a 100644 --- a/components/record-mode/RecordModeOverlay.tsx +++ b/components/record-mode/RecordModeOverlay.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useRecordMode } from './RecordModeContext'; -import { Reorder, AnimatePresence } from 'framer-motion'; +import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion'; import { Play, Square, @@ -146,438 +146,460 @@ export function RecordModeOverlay() { } return ( -
- {/* 1. Global Toolbar - Slim Industrial Bar */} -
-
- {/* Identity Tag */} -
-
-
- - Event Builder - - - Manual Mode - -
-
- -
- - {/* Action Tools */} -
- - - - - -
- -
- - {/* Sequence Controls */} -
- - - + + Manual Mode + +
+
- + + + + - - -
- -
- - -
-
- - {/* 2. Event Timeline Popover */} - {showEvents && ( -
-
-
-
-

Recording Track

-

- {events.length} Actions Recorded -

-
-
- - {events.length === 0 ? ( -
- -

Timeline is empty

-
- ) : ( - events.map((event, index) => ( - setHoveredEventId(event.id)} - onMouseLeave={() => setHoveredEventId(null)} - > -
- -
+
-
-
- - {event.type === 'mouse' ? `Mouse (${event.interactionType})` : event.type} - - {event.clickOrigin && - event.clickOrigin !== 'center' && - event.interactionType === 'click' && ( - - {event.clickOrigin} - - )} - - {event.duration}ms - -
-

- {event.selector || 'system:wait'} -

-
- -
- - -
- - )) - )} - -
-
- )} - - {/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */} - - {/* Picking Tooltip */} - {pickingMode && ( -
-
-
-
- - Assigning {pickingMode} - -
-
- -
-
- )} - - - - {/* 3. Event Options Panel (Sidebar-like) */} - - {editingEventId && ( -
-
-

- Event Options -

+ {/* Sequence Controls */} +
-
-
- {/* Type Display */} -
- -
- + + - - - -
-
- - {/* Precise Click Origin */} - {editForm.type === 'mouse' && editForm.interactionType === 'click' && ( -
- -
- {[ - { id: 'top-left', label: 'TL' }, - { id: 'top-right', label: 'TR' }, - { id: 'center', label: 'CTR' }, - { id: 'bottom-left', label: 'BL' }, - { id: 'bottom-right', label: 'BR' }, - ].map((origin) => ( - - ))} -
-
- )} - - {/* Timing */} -
- - - setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) })) + } catch (e) { + console.error(e); + alert('Error saving session'); } - className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent" - /> + }} + disabled={events.length === 0} + className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400" + title="Save to Project (Dev)" + > + + + + +
+ +
+ + +
+
+ + {/* 2. Event Timeline Popover */} + {showEvents && ( +
+
+
+
+

Recording Track

+

+ {events.length} Actions Recorded +

+
+
- {/* Zoom & Effects */} -
-
-
- - - Zoom Shift - + + {events.length === 0 ? ( +
+ +

Timeline is empty

+ ) : ( + events.map((event, index) => ( + setHoveredEventId(event.id)} + onMouseLeave={() => setHoveredEventId(null)} + > +
+ +
+ +
+
+ + {event.type === 'mouse' + ? `Mouse (${event.interactionType})` + : event.type} + + {event.clickOrigin && + event.clickOrigin !== 'center' && + event.interactionType === 'click' && ( + + {event.clickOrigin} + + )} + + {event.duration}ms + +
+

+ {event.selector || 'system:wait'} +

+
+ +
+ + +
+
+ )) + )} +
+
+
+ )} + + {/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */} + + {/* Picking Tooltip */} + {pickingMode && ( +
+
+
+
+ + Assigning {pickingMode} + +
+
+ +
+
+ )} + + + + {/* 3. Event Options Panel (Sidebar-like) */} + + {editingEventId && ( +
+
+

+ Event Options +

+ +
+ +
+ {/* Type Display */} +
+ +
+ + + + +
+
+ + {/* Precise Click Origin */} + {editForm.type === 'mouse' && editForm.interactionType === 'click' && ( +
+ +
+ {[ + { id: 'top-left', label: 'TL' }, + { id: 'top-right', label: 'TR' }, + { id: 'center', label: 'CTR' }, + { id: 'bottom-left', label: 'BL' }, + { id: 'bottom-right', label: 'BR' }, + ].map((origin) => ( + + ))} +
+
+ )} + + {/* Timing */} +
+ - setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) })) + setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) })) } - className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono" + className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent" />
- - {editForm.type === 'mouse' && editForm.interactionType === 'click' && ( - )} -
-
- -
- )} - -
+ {editForm.type === 'mouse' && editForm.interactionType === 'click' && ( + + )} +
+
+ + +
+ )} + +
+ ); } diff --git a/lib/services/errors/glitchtip-error-reporting-service.ts b/lib/services/errors/glitchtip-error-reporting-service.ts index 4289592d..79f32697 100644 --- a/lib/services/errors/glitchtip-error-reporting-service.ts +++ b/lib/services/errors/glitchtip-error-reporting-service.ts @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/nextjs'; import type { ErrorReportingLevel, ErrorReportingService, @@ -7,32 +6,66 @@ import type { import type { NotificationService } from '../notifications/notification-service'; import type { LoggerService } from '../logging/logger-service'; -type SentryLike = typeof Sentry; - export type GlitchtipErrorReportingServiceOptions = { enabled: boolean; }; // GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN. +// Sentry is dynamically imported to avoid a ~100KB main-thread execution penalty on initial load. export class GlitchtipErrorReportingService implements ErrorReportingService { private logger: LoggerService; + private sentryPromise: Promise | null = null; constructor( private readonly options: GlitchtipErrorReportingServiceOptions, logger: LoggerService, private readonly notifications?: NotificationService, - private readonly sentry: SentryLike = Sentry, ) { this.logger = logger.child({ component: 'error-reporting-glitchtip' }); + + if (this.options.enabled) { + if (typeof window !== 'undefined') { + // On client-side, wait until idle before fetching Sentry + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => { + this.getSentry(); + }); + } else { + setTimeout(() => { + this.getSentry(); + }, 3000); + } + } else { + // Pre-fetch on server-side + this.getSentry(); + } + } + } + + private getSentry(): Promise { + if (!this.sentryPromise) { + this.sentryPromise = import('@sentry/nextjs').then((Sentry) => { + // Client-side initialization must happen here since sentry.client.config.ts is empty + if (typeof window !== 'undefined') { + Sentry.init({ + dsn: 'https://public@errors.infra.mintel.me/1', + tunnel: '/errors/api/relay', + enabled: true, + tracesSampleRate: 0, + replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + }); + } + return Sentry; + }); + } + return this.sentryPromise; } async captureException(error: unknown, context?: Record) { if (!this.options.enabled) return undefined; - const result = this.sentry.captureException(error, context as any) as any; // Send to Gotify if it's considered critical or if we just want all exceptions there - // For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages" - // We'll treat all captureException calls as potentially critical or at least noteworthy if (this.notifications) { const errorMessage = error instanceof Error ? error.message : String(error); const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : ''; @@ -44,34 +77,33 @@ export class GlitchtipErrorReportingService implements ErrorReportingService { }); } - return result; + const Sentry = await this.getSentry(); + return Sentry.captureException(error, context as any) as any; } - captureMessage(message: string, level: ErrorReportingLevel = 'error') { + async captureMessage(message: string, level: ErrorReportingLevel = 'error') { if (!this.options.enabled) return undefined; - return this.sentry.captureMessage(message, level as any) as any; + const Sentry = await this.getSentry(); + return Sentry.captureMessage(message, level as any) as any; } setUser(user: ErrorReportingUser | null) { if (!this.options.enabled) return; - this.sentry.setUser(user as any); + this.getSentry().then((Sentry) => Sentry.setUser(user as any)); } setTag(key: string, value: string) { if (!this.options.enabled) return; - this.sentry.setTag(key, value); + this.getSentry().then((Sentry) => Sentry.setTag(key, value)); } - withScope(fn: () => T, context?: Record) { + withScope(fn: () => T, context?: Record): T { if (!this.options.enabled) return fn(); - return this.sentry.withScope((scope) => { - if (context) { - for (const [key, value] of Object.entries(context)) { - scope.setExtra(key, value); - } - } - return fn(); - }); + // Since withScope mandates executing fn() synchronously to return T, + // and Sentry load is async, if context mapping is absolutely required + // for this feature we would need an async API. + // For now we degrade gracefully by just executing the function. + return fn(); } } diff --git a/next.config.mjs b/next.config.mjs index 38c47b71..79c47cc4 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -10,6 +10,9 @@ const nextConfig = { // Make sure entries are not disposed too quickly maxInactiveAge: 60 * 1000, }, + experimental: { + optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'], + }, productionBrowserSourceMaps: false, logging: { fetches: { diff --git a/scripts/replace-motion.cjs b/scripts/replace-motion.cjs new file mode 100644 index 00000000..3f9a4252 --- /dev/null +++ b/scripts/replace-motion.cjs @@ -0,0 +1,21 @@ +const fs = require('fs'); +const files = [ + '/Users/marcmintel/Projects/klz-2026/components/Header.tsx', + '/Users/marcmintel/Projects/klz-2026/components/Scribble.tsx', + '/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx', + '/Users/marcmintel/Projects/klz-2026/components/record-mode/RecordModeOverlay.tsx', + '/Users/marcmintel/Projects/klz-2026/components/record-mode/PlaybackCursor.tsx' +]; + +for (const file of files) { + let content = fs.readFileSync(file, 'utf8'); + content = content.replace(/import { motion } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation } from 'framer-motion';"); + content = content.replace(/import { motion, Variants } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';"); + content = content.replace(/import { motion, AnimatePresence } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';"); + + content = content.replace(/