From b43c9d851be735e59b8829d07c5abd1c67f11329 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 6 Jun 2026 19:52:11 -0400 Subject: [PATCH] Homepage: Server Component + client splash island (cut hydration render-delay) The data-URI logo still had LCP ~5s with zero network request, proving the bottleneck is render delay (JS), not the image. So: - page.tsx is now a Server Component. The hero, tagline, buttons, ServicesPreview, PortfolioGrid, and FounderSignature render as static server HTML (they had no client features, so they now hydrate to nothing). - Splash state machine + header/nav/background gating + nebula position + scroll restoration moved into a small client island (home-shell.tsx) that takes the static content as children. Nebula reads the title via getElementById. - Dedupe @types/react to 19 across the workspace (pnpm override) so the server->client children boundary type-checks. --- apps/web/src/app/page.tsx | 212 +++++------------- .../web/src/components/landing/home-shell.tsx | 115 ++++++++++ package.json | 2 + pnpm-lock.yaml | 43 ++-- 4 files changed, 192 insertions(+), 180 deletions(-) create mode 100644 apps/web/src/components/landing/home-shell.tsx diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 20201d3..82cb057 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,171 +1,77 @@ -"use client"; - import { PortfolioGrid } from "@/components/portfolio/bento-grid"; import { ContactForm } from "@/components/contact/contact-form"; import { FounderSignature } from "@/components/about/founder-signature"; import { ServicesPreview } from "@/components/landing/services-preview"; -import { Header } from "@/components/layout/header"; -import { MobileNav } from "@/components/layout/mobile-nav"; -import { DynamicBackground } from "@/components/landing/dynamic-background"; -import { HeroSplash } from "@/components/landing/hero-splash"; import { ScrollReveal } from "@/components/scroll-reveal"; +import { HomeShell } from "@/components/landing/home-shell"; import { Button } from "@repo/ui/components/button"; import Link from "next/link"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; - -// Avoid the "useLayoutEffect does nothing on the server" warning during SSR. -const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; +// Server Component: the hero and sections render as static server HTML. Only the +// splash, header, nav, background (in HomeShell) and the scroll-reveal / contact +// islands ship as client JS. ServicesPreview, PortfolioGrid, and FounderSignature +// have no client features, so they hydrate to nothing. export default function Home() { - // The splash owns the first screen. Once the visitor scrolls through it (or hits - // "Explore"), we "enter" the site: the splash unmounts for good and the real hero, - // header, bottom nav, and background take over. Scrolling back up lands on the hero, - // never the splash again. - const [entered, setEntered] = useState(false); - // `splashUp` keeps the splash mounted through its fade-out; `leaving` triggers the - // cross-fade overlay (button path only). - const [splashUp, setSplashUp] = useState(true); - const [leaving, setLeaving] = useState(false); - const splashRef = useRef(null); - const restoreScroll = useRef(0); - - // Respect prefers-reduced-motion: skip the splash entirely and land straight on - // the main hero, on desktop and mobile. Runs before paint, so the splash never - // flashes for these visitors. - useIsoLayoutEffect(() => { - const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - // The splash is a one-time intro per session: once entered, a refresh (even - // from mid-page) skips it so you can't scroll back up into it. - const alreadyEntered = sessionStorage.getItem("such:entered") === "1"; - if (reduced || alreadyEntered) { - setEntered(true); - setSplashUp(false); - } - }, []); - - // Deliberately place the nebula/monitor just to the left of the hero title, at any - // screen width (restored from the original hero). - const titleRef = useRef(null); - const [nebulaPosition, setNebulaPosition] = useState<{ x: number; y: number } | null>(null); - - // Dismiss the splash once it has fully scrolled out of view. - useEffect(() => { - if (entered) return; - const el = splashRef.current; - if (!el) return; - const io = new IntersectionObserver( - ([entry]) => { - if (!entry.isIntersecting) { - restoreScroll.current = Math.max(0, window.scrollY - el.offsetHeight); - sessionStorage.setItem("such:entered", "1"); - setEntered(true); - setSplashUp(false); // scrolled past: just unmount, no overlay fade needed - } - }, - { threshold: 0 }, - ); - io.observe(el); - return () => io.disconnect(); - }, [entered]); - - // After the splash unmounts, keep the visitor's position in the content stable - // (no jump). Runs before paint. - useIsoLayoutEffect(() => { - if (entered) window.scrollTo(0, restoreScroll.current); - }, [entered]); - - // Compute the nebula position from the title. Recompute once entered (the title is - // now in its final on-screen position) and on resize / font load. - useEffect(() => { - const updatePosition = () => { - if (!titleRef.current) return; - const rect = titleRef.current.getBoundingClientRect(); - const titleLeftPercent = (rect.left / window.innerWidth) * 100; - const titleTopPercent = (rect.top / window.innerHeight) * 100; - setNebulaPosition({ - x: Math.max(5, titleLeftPercent - 8), - y: Math.max(10, titleTopPercent - 2), - }); - }; - updatePosition(); - window.addEventListener("resize", updatePosition); - document.fonts?.ready.then(updatePosition); - return () => window.removeEventListener("resize", updatePosition); - }, [entered]); - - const enterFromButton = () => { - restoreScroll.current = 0; - sessionStorage.setItem("such:entered", "1"); - setEntered(true); - setLeaving(true); - // Keep the splash mounted briefly as a fixed, fading overlay so it cross-fades - // into the revealed content instead of hard-cutting. - setTimeout(() => setSplashUp(false), 800); - }; - return ( -
- -
+ +
+

+ Precision Engineering + for Everyone +

+

+ Custom e-commerce, websites, apps, and consulting. Plus our own mobile apps and games. A software studio in Kennett Square, PA. +

- {splashUp && } +
+ + + + + + +
-
-

- Precision Engineering - for Everyone -

-

- Custom e-commerce, websites, apps, and consulting. Plus our own mobile apps and games. A software studio in Kennett Square, PA. -

+ -
- - - - - - -
+ +
+

Selected Works

+

+ Recent projects spanning e-commerce, games, payment infrastructure, and interactive web applications. +

+
+ +
- + +
+

Inclusive by Design

+

+ We believe the web is for everyone. Every line of code we write is + audited for WCAG 2.2 compliance and high-performance across all devices. +

+
+ +
- -
-

Selected Works

-

- Recent projects spanning e-commerce, games, payment infrastructure, and interactive web applications. -

-
- -
- - -
-

Inclusive by Design

-

- We believe the web is for everyone. Every line of code we write is - audited for WCAG 2.2 compliance and high-performance across all devices. -

-
- -
- - -
-

Start a Project

-

- Have a project in mind? Let's talk about what you need and how we can help. -

-
- -
+ +
+

Start a Project

+

+ Have a project in mind? Let's talk about what you need and how we can help. +

+
+ +
- -
+ ); } diff --git a/apps/web/src/components/landing/home-shell.tsx b/apps/web/src/components/landing/home-shell.tsx new file mode 100644 index 0000000..c73ba48 --- /dev/null +++ b/apps/web/src/components/landing/home-shell.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { DynamicBackground } from "@/components/landing/dynamic-background"; +import { Header } from "@/components/layout/header"; +import { MobileNav } from "@/components/layout/mobile-nav"; +import { HeroSplash } from "@/components/landing/hero-splash"; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; + +// Avoid the "useLayoutEffect does nothing on the server" warning during SSR. +const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +/** + * Client shell for the homepage. Owns the splash state machine, the entered-gating + * of the header / nav / background, the nebula position, and scroll restoration. + * The page itself is a Server Component and passes the static hero + sections in as + * `children`, so all of that renders as server HTML instead of shipping as client + * JS (smaller hydration tree = faster, more stable LCP). + */ +export function HomeShell({ children }: { children: ReactNode }) { + // The splash owns the first screen. Once the visitor scrolls through it (or hits + // "Explore"), we "enter" the site: the splash unmounts for good and the real hero, + // header, bottom nav, and background take over. Scrolling back up lands on the hero, + // never the splash again. + const [entered, setEntered] = useState(false); + // `splashUp` keeps the splash mounted through its fade-out; `leaving` triggers the + // cross-fade overlay (button path only). + const [splashUp, setSplashUp] = useState(true); + const [leaving, setLeaving] = useState(false); + const splashRef = useRef(null); + const restoreScroll = useRef(0); + const [nebulaPosition, setNebulaPosition] = useState<{ x: number; y: number } | null>(null); + + // Respect prefers-reduced-motion: skip the splash entirely and land straight on + // the main hero, on desktop and mobile. Runs before paint, so the splash never + // flashes for these visitors. + useIsoLayoutEffect(() => { + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + // The splash is a one-time intro per session: once entered, a refresh (even + // from mid-page) skips it so you can't scroll back up into it. + const alreadyEntered = sessionStorage.getItem("such:entered") === "1"; + if (reduced || alreadyEntered) { + setEntered(true); + setSplashUp(false); + } + }, []); + + // Dismiss the splash once it has fully scrolled out of view. + useEffect(() => { + if (entered) return; + const el = splashRef.current; + if (!el) return; + const io = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) { + restoreScroll.current = Math.max(0, window.scrollY - el.offsetHeight); + sessionStorage.setItem("such:entered", "1"); + setEntered(true); + setSplashUp(false); // scrolled past: just unmount, no overlay fade needed + } + }, + { threshold: 0 }, + ); + io.observe(el); + return () => io.disconnect(); + }, [entered]); + + // After the splash unmounts, keep the visitor's position in the content stable + // (no jump). Runs before paint. + useIsoLayoutEffect(() => { + if (entered) window.scrollTo(0, restoreScroll.current); + }, [entered]); + + // Place the nebula/monitor just to the left of the hero title, at any screen width. + // The title lives in the server-rendered children, so read it by id. + useEffect(() => { + const updatePosition = () => { + const title = document.getElementById("hero-title"); + if (!title) return; + const rect = title.getBoundingClientRect(); + const titleLeftPercent = (rect.left / window.innerWidth) * 100; + const titleTopPercent = (rect.top / window.innerHeight) * 100; + setNebulaPosition({ + x: Math.max(5, titleLeftPercent - 8), + y: Math.max(10, titleTopPercent - 2), + }); + }; + updatePosition(); + window.addEventListener("resize", updatePosition); + document.fonts?.ready.then(updatePosition); + return () => window.removeEventListener("resize", updatePosition); + }, [entered]); + + const enterFromButton = () => { + restoreScroll.current = 0; + sessionStorage.setItem("such:entered", "1"); + setEntered(true); + setLeaving(true); + // Keep the splash mounted briefly as a fixed, fading overlay so it cross-fades + // into the revealed content instead of hard-cutting. + setTimeout(() => setSplashUp(false), 800); + }; + + return ( +
+ +
+ + {splashUp && } + + {children} + + +
+ ); +} diff --git a/package.json b/package.json index a64afcd..3d1796a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ }, "pnpm": { "overrides": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "yaml": "^2.8.3", "flatted": "^3.4.2", "minimatch": "^9.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdb0359..0b7a504 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: + '@types/react': ^19.2.14 + '@types/react-dom': ^19.2.3 yaml: ^2.8.3 flatted: ^3.4.2 minimatch: ^9.0.7 @@ -19,8 +21,8 @@ importers: .: dependencies: '@types/react': - specifier: ^18.0.0 - version: 18.3.27 + specifier: ^19.2.14 + version: 19.2.14 devDependencies: eslint: specifier: ^9.0.0 @@ -136,10 +138,10 @@ importers: dependencies: '@radix-ui/react-slot': specifier: ^1.2.4 - version: 1.2.4(@types/react@18.3.27)(react@18.3.1) + version: 1.2.4(@types/react@19.2.14)(react@18.3.1) '@types/react': - specifier: ^18.0.0 - version: 18.3.27 + specifier: ^19.2.14 + version: 19.2.14 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -430,7 +432,7 @@ packages: '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: - '@types/react': '>=16' + '@types/react': ^19.2.14 react: '>=16' '@napi-rs/wasm-runtime@0.2.12': @@ -514,7 +516,7 @@ packages: '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: - '@types/react': '*' + '@types/react': ^19.2.14 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -523,7 +525,7 @@ packages: '@radix-ui/react-slot@1.2.4': resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} peerDependencies: - '@types/react': '*' + '@types/react': ^19.2.14 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': @@ -586,16 +588,10 @@ packages: '@types/node@20.19.30': resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@18.3.27': - resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/react': ^19.2.14 '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2981,18 +2977,18 @@ snapshots: dependencies: playwright: 1.57.0 - '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.27)(react@18.3.1)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: - '@types/react': 18.3.27 + '@types/react': 19.2.14 - '@radix-ui/react-slot@1.2.4(@types/react@18.3.27)(react@18.3.1)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@18.3.1)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@18.3.1) react: 18.3.1 optionalDependencies: - '@types/react': 18.3.27 + '@types/react': 19.2.14 '@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: @@ -3055,17 +3051,10 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/prop-types@15.7.15': {} - '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 - '@types/react@18.3.27': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - '@types/react@19.2.14': dependencies: csstype: 3.2.3