Files
such.software/apps/web/src/components/landing/home-shell.tsx
T
jwinterm 835c5c8084 feat(splash): a colour burst as the mark hands over to the page
Subtle and half-transparent, per the brief. Four blobs, one per monogram cell,
each in that letter's own colour, thrown outward from where the letter was: the
mark comes apart into its own palette rather than flashing something unrelated.
The peak opacity is 0.42 light / 0.5 dark and the gradients never reach full
alpha, so it stays a wash rather than a curtain.

Only transform and opacity animate. Both are compositor properties, so this
does not repaint per frame. That is deliberate: animating a filter is what
pegged a core on the matrix animation, because a filter change re-renders the
whole filter graph every frame. Soft edges come from a static radial-gradient
that is painted once and only ever transformed.

The overlay could no longer fade as one piece. Ground and mark fade in 260ms
while the burst runs 420ms plus a 25ms per-blob stagger, so fading the
container would have taken the burst down with it halfway through. They fade
independently now and the shell unmounts at 540ms instead of 300ms, which is
the burst's real length rather than the cross-fade's.

Hidden entirely under prefers-reduced-motion, though those visitors never
reach the splash anyway.
2026-08-17 14:19:56 -04:00

160 lines
7.4 KiB
TypeScript

"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 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);
}
}, []);
// The splash is a fixed overlay now, so the hero is already in flow beneath it and
// would otherwise scroll around behind the cover. Freeze the page while it is up.
// (The previous scroll-past-to-dismiss IntersectionObserver is gone with it: an
// overlay never leaves the viewport, so it could never have fired. Scroll intent
// still enters the site, via the wheel/touch/key handlers inside the splash.)
useEffect(() => {
if (entered || !splashUp) return;
const html = document.documentElement;
const prev = html.style.overflow;
html.style.overflow = "hidden";
return () => {
html.style.overflow = prev;
};
}, [entered, splashUp]);
// 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(() => {
let lastWidth = window.innerWidth;
const updatePosition = () => {
const title = document.getElementById("hero-title");
if (!title) return;
const rect = title.getBoundingClientRect();
// Use the title's document-absolute top (rect.top + scrollY), not its
// viewport-relative top, so the value does not shift as the page scrolls.
// Mobile browsers fire `resize` on scroll when the address bar hides, and a
// viewport-relative read at that moment is what made the monitor jump.
const titleLeftPercent = (rect.left / window.innerWidth) * 100;
const titleTopPercent = ((rect.top + window.scrollY) / window.innerHeight) * 100;
setNebulaPosition({
x: Math.max(5, titleLeftPercent - 8),
y: Math.max(10, titleTopPercent - 2),
});
};
updatePosition();
// Recompute only on a real width change (orientation / window resize). Ignore
// height-only changes, which on mobile are just the address bar showing or hiding.
const onResize = () => {
if (window.innerWidth === lastWidth) return;
lastWidth = window.innerWidth;
updatePosition();
};
window.addEventListener("resize", onResize);
document.fonts?.ready.then(updatePosition);
return () => window.removeEventListener("resize", onResize);
}, [entered]);
const enterFromButton = () => {
restoreScroll.current = 0;
sessionStorage.setItem("such:entered", "1");
setEntered(true);
setLeaving(true);
// Scroll-intent entries arrive with wheel/touch MOMENTUM still flowing; without a
// lock those trailing events scroll the freshly-revealed page down during the wave,
// so you land below the hero instead of at the very top (same as a click would).
// Freeze scrolling for the transition, then release exactly at the top.
const html = document.documentElement;
const prevOverflow = html.style.overflow;
html.style.overflow = "hidden";
window.scrollTo(0, 0);
// Any scroll that sneaks past the overflow lock gets yanked back to 0.
const pin = () => window.scrollTo(0, 0);
window.addEventListener("scroll", pin, { passive: true });
// The gesture that triggered the enter keeps emitting MOMENTUM (a trackpad flick
// can trail for ~1.5s, longer than the 800ms wave). Releasing the lock on a fixed
// timer let those trailing ticks scroll the revealed page, so you landed below the
// hero instead of at the very top. Hold the lock until the input goes QUIET
// (250ms with no wheel/touch), capped at 3s so a deliberate scroller is never trapped.
let lastInput = performance.now();
const note = () => { lastInput = performance.now(); };
window.addEventListener("wheel", note, { passive: true });
window.addEventListener("touchmove", note, { passive: true });
const release = () => {
window.removeEventListener("scroll", pin);
window.removeEventListener("wheel", note);
window.removeEventListener("touchmove", note);
html.style.overflow = prevOverflow;
window.scrollTo(0, 0);
};
const started = performance.now();
const tryRelease = () => {
const now = performance.now();
if (now - lastInput > 250 || now - started > 3000) release();
else setTimeout(tryRelease, 100);
};
// Keep the splash mounted briefly as a fixed, fading overlay so it cross-fades
// into the revealed content instead of hard-cutting; release the scroll lock only
// once the wave is done AND the triggering gesture's momentum has died down.
setTimeout(() => {
setSplashUp(false);
tryRelease();
}, 540); // the 420ms burst plus its 75ms stagger, not just the 260ms cross-fade:
// unmounting on the fade alone cut the blobs off mid-flight
};
return (
<main className="relative flex flex-col items-center overflow-hidden">
<DynamicBackground nebulaPosition={nebulaPosition} visible={entered} />
<Header floating visible={entered} />
{splashUp && <HeroSplash onEnter={enterFromButton} leaving={leaving} />}
{children}
<MobileNav visible={entered} />
</main>
);
}