diff --git a/src/components/loader/PageLoader.tsx b/src/components/loader/PageLoader.tsx new file mode 100644 index 0000000..df724b3 --- /dev/null +++ b/src/components/loader/PageLoader.tsx @@ -0,0 +1,52 @@ +/** + * Full-screen loader shown until the page's entry animations are ready + * (see ./gate.ts). It's just the logo, animating: the "B" draws itself in + * and fills, the five base facets trace in after it, then a wave runs along + * the facets until the page is ready. Pure CSS, so it plays before any JS. + * + * Plain server-rendered markup driven only by classes on , so React + * never touches it after hydration: + * + * - visible only under `html.motion` (set by the head script), so no-JS and + * reduced-motion visitors never see it + * - `html.loader-done` fading out / hidden + * - `html.loader-gone` fully hidden; animations paused + * - `html.loader-leaving` fading back in for a page exit + */ + +/** Base facets, left → right along the zigzag (wave order). */ +const FACETS = [ + "M151.94,875.51L151.94,1042.32L0,958.98L0,786.14L151.94,875.51Z", + "M306.83,789.02L306.83,957.3L151.94,1042.32L151.94,875.51L156.15,873.19L306.83,789.02Z", + "M461.71,874.03L461.71,1042.32L306.83,957.3L306.83,789.02L461.71,874.03Z", + "M610.63,784.67L610.63,952.95L461.71,1042.32L461.71,874.03L464.52,872.35L610.63,784.67Z", + "M610.63,619.33L610.63,784.67L461.71,702.53L468.56,698.7L563.9,645.44L610.63,619.33Z", +]; + +const LETTER = + "M610.63,515.37L610.63,619.37L590.72,607.63L461.72,531.55L306.83,617L151.94,702.39L138.33,709.89L0,786.14L0,0L362,0C434.173,0 490.98,15.707 532.42,47.12C534.38,48.613 536.297,50.137 538.17,51.69C575.177,82.423 593.98,125.773 594.58,181.74L594.58,184.49C594.58,225.95 583.717,260.877 561.99,289.27C540.263,317.663 511.353,336.547 475.26,345.92C518.027,355.92 551.283,376.477 575.03,407.59C598.777,438.703 610.643,474.63 610.63,515.37ZM368.49,519.89C378.163,512.21 383,499.673 383,482.28C383,448.193 363.28,431.15 323.84,431.15L222.6,431.15L222.6,531.42L323.87,531.42C343.923,531.42 358.797,527.577 368.49,519.89ZM368,227.61C368,210.23 363.153,197.36 353.46,189C343.767,180.64 328.893,176.463 308.84,176.47L222.6,176.47L222.6,276.74L308.83,276.74C328.877,276.74 343.75,272.74 353.45,264.74C363.15,256.74 368,244.363 368,227.61Z"; + +export default function PageLoader() { + return ( + + ); +} diff --git a/src/components/loader/gate.ts b/src/components/loader/gate.ts new file mode 100644 index 0000000..905d290 --- /dev/null +++ b/src/components/loader/gate.ts @@ -0,0 +1,175 @@ +/** + * # Loader gate + * + * Keeps the page loader (see ./PageLoader.tsx) up until everything that + * animates on load is ready, then dismisses it and opens the gate so the + * entry animations start together, in view. + * + * - `holdLoader(label)` registers something the loader must wait for and + * returns an idempotent `release()`. (Motion system + fonts, each 3D scene.) + * - `onLoaderDone(cb)` runs `cb` once the loader starts leaving (immediately + * if it already has). Entry animations hook in here. + * - `showLoader()` brings the overlay back to cover the page before an + * internal navigation, so the next page's loader takes over seamlessly. + * + * Once everything is ready the loader lingers for `READY_HOLD` (and at + * least until the logo has drawn itself in) before it leaves. + * + * State lives on `window` so separately bundled chunks share one gate. + * The loader is dismissed anyway after `MAX_WAIT` so a slow or failed + * asset never traps the visitor. + */ + +const DONE_CLASS = "loader-done"; +const GONE_CLASS = "loader-gone"; +const LEAVING_CLASS = "loader-leaving"; + +/** Pause after the page is ready, before the loader leaves. */ +const READY_HOLD = 1000; +/** Never leave before the logo has finished drawing itself in. */ +const MIN_SHOW = 1400; +/** Hard limit: leave regardless of anything still pending. */ +const MAX_WAIT = 8000; +/** Overlay fade-out (matches the CSS); its animations pause after. */ +const FADE_OUT = 700; + +type GateState = { + pending: Set; + /** Dismissal has started (no new holds accepted). */ + done: boolean; + /** Overlay is leaving; entry animations may run. */ + open: boolean; + listeners: (() => void)[]; + settleRaf: number; + settleTimer: number; +}; + +declare global { + interface Window { + __LOADER?: GateState; + } +} + +const root = () => document.documentElement; + +function gate(): GateState { + if (window.__LOADER) return window.__LOADER; + // The head script may already have dropped the loader (JS was slow). + const already = root().classList.contains(DONE_CLASS); + const state: GateState = { + pending: new Set(), + done: already, + open: already, + listeners: [], + settleRaf: 0, + settleTimer: 0, + }; + window.__LOADER = state; + if (!state.done) { + const remaining = MAX_WAIT - performance.now(); + window.setTimeout(() => dismiss(true), Math.max(remaining, 0)); + } + return state; +} + +function dismiss(force = false) { + const state = gate(); + if (state.done) return; + state.done = true; + if (force && state.pending.size) { + console.warn("Loader timed out waiting for:", [...state.pending]); + } + root().classList.remove(LEAVING_CLASS); + root().classList.add(DONE_CLASS); + state.open = true; + window.setTimeout(() => { + if (root().classList.contains(DONE_CLASS)) { + root().classList.add(GONE_CLASS); + } + }, FADE_OUT + 50); + state.listeners.splice(0).forEach((cb) => { + try { + cb(); + } catch (error) { + console.error(error); + } + }); +} + +function cancelSettle(state: GateState) { + cancelAnimationFrame(state.settleRaf); + window.clearTimeout(state.settleTimer); +} + +/** + * All holds released: wait a couple of frames for late mounts, then hold + * for `READY_HOLD` before leaving. A new hold in the meantime cancels it. + */ +function settle(state: GateState) { + cancelSettle(state); + state.settleRaf = requestAnimationFrame(() => { + state.settleRaf = requestAnimationFrame(() => { + if (state.pending.size || state.done) return; + const wait = Math.max( + READY_HOLD, + MIN_SHOW - performance.now(), + ); + state.settleTimer = window.setTimeout(() => { + if (!state.pending.size) dismiss(); + }, wait); + }); + }); +} + +let holdId = 0; + +export function holdLoader(label: string): () => void { + const state = gate(); + if (state.done) return () => {}; + const key = `${label}#${++holdId}`; + state.pending.add(key); + cancelSettle(state); + + let released = false; + return () => { + if (released) return; + released = true; + state.pending.delete(key); + if (!state.done && !state.pending.size) settle(state); + }; +} + +export function onLoaderDone(cb: () => void): () => void { + const state = gate(); + if (state.open) { + cb(); + return () => {}; + } + state.listeners.push(cb); + return () => { + const i = state.listeners.indexOf(cb); + if (i >= 0) state.listeners.splice(i, 1); + }; +} + +export function isLoaderDone() { + return gate().open; +} + +/** + * Fade the overlay back in (without the logo) and resolve once the page + * is covered. Used for page exits. + */ +export function showLoader(duration = 320): Promise { + const el = root(); + el.classList.add(LEAVING_CLASS); + el.classList.remove(DONE_CLASS, GONE_CLASS); + return new Promise((resolve) => window.setTimeout(resolve, duration)); +} + +/** Undo `showLoader()` (page restored from the back/forward cache). */ +export function hideLoader() { + const el = root(); + el.classList.remove(LEAVING_CLASS); + el.classList.add(DONE_CLASS, GONE_CLASS); +} diff --git a/src/components/motion/head-script.ts b/src/components/motion/head-script.ts index a51d087..4ba0e33 100644 --- a/src/components/motion/head-script.ts +++ b/src/components/motion/head-script.ts @@ -1,11 +1,17 @@ /** * Inline script: runs before first paint so motion elements can be - * pre-hidden without a flash. Skipped for reduced-motion users. If the motion - * system hasn't booted within 3s (JS error, slow network), everything is - * revealed so content is never stuck invisible. + * pre-hidden without a flash and the page loader (src/components/loader) is + * up from the first frame. Skipped for reduced-motion users (no loader, no + * pre-hiding). + * + * Fallbacks so content is never stuck invisible: if the motion system hasn't + * booted within 3s everything is revealed, and if the loader gate never + * initialised (JS error, very slow network) the loader is dropped at 5s. + * Once the gate is up it enforces its own time limit. */ export const MotionHeadScript = `(function(){try{ if(window.matchMedia("(prefers-reduced-motion: reduce)").matches)return; var d=document.documentElement;d.classList.add("motion"); setTimeout(function(){if(!window.__MOTION_READY)d.classList.add("motion-fallback");},3000); +setTimeout(function(){if(!window.__LOADER)d.classList.add("loader-done");},5000); }catch(e){}})();`; diff --git a/src/components/motion/motion.ts b/src/components/motion/motion.ts index 4d9e40a..6ec398f 100644 --- a/src/components/motion/motion.ts +++ b/src/components/motion/motion.ts @@ -1,6 +1,7 @@ import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; +import { hideLoader, onLoaderDone, showLoader } from "../loader/gate"; /** * # Site-wide motion system @@ -19,6 +20,8 @@ import { SplitText } from "gsap/SplitText"; * * Elements are pre-hidden by CSS only once `html.motion` is set by the inline * head script, so no-JS / reduced-motion visitors always see content. + * Nothing is animated until the page loader is dismissed (../loader/gate.ts), + * so above-the-fold entries play in view instead of behind the overlay. */ type Variant = @@ -193,8 +196,9 @@ function scan(root: ParentNode) { }); } -/** Fade the page out before following internal links. */ +/** Cover the page with the loader before following internal links. */ function setupPageExit() { + let leaving = false; const onClick = (e: MouseEvent) => { if (e.defaultPrevented || e.button !== 0) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; @@ -206,17 +210,15 @@ function setupPageExit() { if (/\.(pdf|png|jpe?g|webp|gif|svg|zip)$/i.test(url.pathname)) return; e.preventDefault(); - gsap.to("main", { - autoAlpha: 0, - y: -12, - duration: 0.28, - ease: "power2.in", - onComplete: () => location.assign(url.href), - }); + if (leaving) return; + leaving = true; + showLoader().then(() => location.assign(url.href)); }; // restore if the page comes back from the back/forward cache const onShow = (e: PageTransitionEvent) => { - if (e.persisted) gsap.set("main", { clearProps: "all" }); + if (!e.persisted) return; + leaving = false; + hideLoader(); }; document.addEventListener("click", onClick); window.addEventListener("pageshow", onShow); @@ -241,8 +243,15 @@ export function initMotion() { registered = true; } - scan(document); setupPageExit(); + cleanups.push(onLoaderDone(start)); + + return destroyMotion; +} + +/** Runs once the loader leaves: animate what's on the page, watch for more. */ +function start() { + scan(document); // Pick up content React mounts later (tabs, lists, client fetches). observer = new MutationObserver((mutations) => { @@ -264,8 +273,6 @@ export function initMotion() { window.addEventListener("load", onLoad); document.fonts?.ready.then(onLoad).catch(() => {}); cleanups.push(() => window.removeEventListener("load", onLoad)); - - return destroyMotion; } function destroyMotion() { diff --git a/src/components/motion/use-motion.tsx b/src/components/motion/use-motion.tsx index af79d45..32e6134 100644 --- a/src/components/motion/use-motion.tsx +++ b/src/components/motion/use-motion.tsx @@ -1,15 +1,30 @@ import React from "react"; +import { holdLoader } from "../loader/gate"; -/** Boot the site-wide GSAP motion system (see ./motion.ts). */ +/** + * Boot the site-wide GSAP motion system (see ./motion.ts). Holds the page + * loader until GSAP is loaded and fonts are settled, so SplitText measures + * final line breaks. + */ export default function useMotion() { React.useEffect(() => { let destroy: (() => void) | undefined; let cancelled = false; - import("./motion").then(({ initMotion }) => { - if (!cancelled) destroy = initMotion(); - }); + const release = holdLoader("motion"); + + Promise.all([ + import("./motion"), + document.fonts?.ready.catch(() => {}), + ]) + .then(([{ initMotion }]) => { + if (!cancelled) destroy = initMotion(); + }) + .catch((error) => console.warn("Motion failed to load", error)) + .finally(release); + return () => { cancelled = true; + release(); destroy?.(); }; }, []); diff --git a/src/components/three/SceneCanvas.tsx b/src/components/three/SceneCanvas.tsx index 79804fb..c8836ce 100644 --- a/src/components/three/SceneCanvas.tsx +++ b/src/components/three/SceneCanvas.tsx @@ -1,5 +1,6 @@ import React from "react"; import { twMerge } from "tailwind-merge"; +import { holdLoader } from "../loader/gate"; /** * Each scene is its own lazily-loaded chunk; three.js + the stage are a @@ -49,6 +50,8 @@ export default function SceneCanvas({ let disposed = false; let dispose: (() => void) | undefined; + // The page loader waits for the first frame (or a failure). + const release = holdLoader(`scene:${scene}`); (async () => { try { @@ -69,18 +72,24 @@ export default function SceneCanvas({ : l.mobile) ?? {} ); }, - onReady: () => setReady(true), + onReady: () => { + setReady(true); + release(); + }, }); if (disposed) handle.dispose(); else dispose = handle.dispose; } catch (error) { // No WebGL / failed load: the page simply stays text-only. console.warn(`SceneCanvas(${scene}) failed`, error); + } finally { + release(); } })(); return () => { disposed = true; + release(); dispose?.(); }; }, [scene]); diff --git a/src/components/three/stage.ts b/src/components/three/stage.ts index d27e059..faacb69 100644 --- a/src/components/three/stage.ts +++ b/src/components/three/stage.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import { gsap } from "gsap"; +import { onLoaderDone } from "../loader/gate"; /** * # Three.js stage @@ -7,7 +8,8 @@ import { gsap } from "gsap"; * Shared renderer / camera / loop for every page graphic. A scene plugs in * by returning a `SceneInstance`; the stage handles framing, resize, pointer * smoothing, the GSAP-driven entry animation, pausing when off-screen and - * reduced motion. + * reduced motion. The scene renders behind the page loader while it's up + * (warming shaders), but the entry only starts once the loader leaves. */ export type Stage = { @@ -147,6 +149,7 @@ export async function createStage(opts: StageOptions) { let last = 0; let elapsed = 0; let entryTween: gsap.core.Tween | undefined; + let gateOpen = false; function loop(now: number) { const dt = Math.min((now - last) / 1000, 0.05); @@ -164,6 +167,11 @@ export async function createStage(opts: StageOptions) { running = true; last = performance.now(); raf = requestAnimationFrame(loop); + beginEntry(); + } + + function beginEntry() { + if (!gateOpen || !running) return; entryTween ??= gsap.to(stage, { entry: 1, duration: opts.entryDuration ?? 2.8, @@ -194,10 +202,16 @@ export async function createStage(opts: StageOptions) { renderFrame(0, 0); opts.onReady?.(); + const offLoader = onLoaderDone(() => { + gateOpen = true; + beginEntry(); + }); + return { stage, dispose() { stop(); + offLoader(); entryTween?.kill(); visibility.disconnect(); resizeObserver.disconnect(); diff --git a/src/layouts/main/index.tsx b/src/layouts/main/index.tsx index a7f5393..9c0b846 100644 --- a/src/layouts/main/index.tsx +++ b/src/layouts/main/index.tsx @@ -5,6 +5,7 @@ import Footer from "./Footer"; import { twMerge } from "tailwind-merge"; import MobileMenu from "./(sections)/MobileMenu"; import useMotion from "@/src/components/motion/use-motion"; +import PageLoader from "@/src/components/loader/PageLoader"; type Props = PropsWithChildren; @@ -23,6 +24,7 @@ export default function Layout({ children }: Props) {