diff --git a/apps/web/public/images/animations/morph_splash.svg b/apps/web/public/images/animations/morph_splash.svg
new file mode 100644
index 0000000..7171471
--- /dev/null
+++ b/apps/web/public/images/animations/morph_splash.svg
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/images/animations/morph_splash_light.svg b/apps/web/public/images/animations/morph_splash_light.svg
new file mode 100644
index 0000000..59525fe
--- /dev/null
+++ b/apps/web/public/images/animations/morph_splash_light.svg
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 7e6d5cc..b71bc59 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -318,4 +318,12 @@
}
/* Soft aura behind the hero title (decorative). Gentle breathing scale/opacity. */
@keyframes hero-aura { 0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); } 50% { opacity: 0.8; transform: translateX(-50%) scale(1.08); } }
-.hero-aura { animation: hero-aura 7s ease-in-out infinite; }
\ No newline at end of file
+.hero-aura { animation: hero-aura 7s ease-in-out infinite; }
+/* Pre-paint splash suppression. The inline
script stamps this attribute when the
+ visitor has already entered this session (or asked for reduced motion), so the
+ server-rendered overlay is hidden in the very first painted frame instead of being
+ removed a frame later by hydration -- which is what the refresh flash was. The overlay
+ is `fixed`, so this removes nothing from the document flow and shifts no layout. */
+html[data-splash="skip"] #splash-overlay {
+ display: none !important;
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index d06f0de..c14b68c 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -111,6 +111,28 @@ export default function RootLayout({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteData) }}
/>
+ {/*
+ The splash is server-rendered, so on a refresh the browser painted it and only
+ THEN did hydration decide this visitor had already entered and remove it. That
+ one painted frame is the flash. React cannot prevent it: even useLayoutEffect
+ runs after the first paint of the server HTML.
+
+ So the decision moves before paint. This script is synchronous and in ,
+ which means it runs before the body is painted; it stamps the root element and
+ a plain CSS rule (globals.css, [data-splash="skip"]) hides the overlay in the
+ same frame it would otherwise have appeared in. The splash is `fixed`, so
+ hiding it moves nothing and costs no layout shift.
+
+ Kept deliberately tiny and dependency-free because it is render-blocking.
+ */}
+
below: an is a valid Largest-Contentful-Paint candidate, whereas an
-// inline is not, which is what was producing NO_LCP on mobile (where the
-// tagline text is hidden, leaving the logo as the only large element).
-
-export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => void; sectionRef?: any; leaving?: boolean }) {
- const [entering, setEntering] = useState(false);
+/**
+ * First-load splash: the pillar signature.
+ *
+ * Four dots become the four pillar symbols become the word SUCH, then the page takes over.
+ * The whole thing is one generated SVG (morph_splash*.svg) whose CSS timeline is scaled to
+ * land on SPLASH_MS, so this component only has to hold it and then get out of the way.
+ *
+ * It is a FIXED overlay from its first paint, never a block in document flow. That is not
+ * cosmetic: in flow, removing it pulled a viewport of height out of the page and shifted
+ * the hero by a full screen (CLS 1.0). A click used to hide that from CLS, since shifts
+ * within 500ms of input are excused, but visitors who never clicked ate it in full.
+ *
+ * Returning visitors never see this at all, and crucially never see a FRAME of it: the
+ * decision is made by a synchronous script in (see layout.tsx) that hides the
+ * overlay before the first paint. Doing it in React cannot work -- even useLayoutEffect
+ * runs after the server HTML has been painted, which is the flash.
+ */
+export function HeroSplash({ onEnter, leaving }: { onEnter: () => void; leaving?: boolean }) {
const [fadeOut, setFadeOut] = useState(false);
- // warmed: set on idle, well after LCP. It (a) paints a 1px element through the water
- // filter so the browser compiles the filter graph BEFORE the first real use (the
- // first-click stutter was the graph compiling mid-transition), and (b) turns on
- // will-change for the logo so its layer is promoted ahead of the zoom. Doing this
- // on requestIdleCallback keeps it invisible to Lighthouse.
- const [warmed, setWarmed] = useState(false);
- const dispRef = useRef(null);
- const turbRef = useRef(null);
- // Latest-handler ref so the window listeners below never hold a stale closure.
+ const entered = useRef(false);
+ // Latest-handler ref so the window listeners never hold a stale closure.
const enterRef = useRef<() => void>(() => {});
- // When the page asks the splash to leave, it first becomes a fixed overlay; one
- // frame later we drop opacity so it cross-fades into the revealed content.
+ // Once asked to leave, drop opacity on the NEXT frame: the browser needs a painted
+ // start value to transition from, and setting both in one frame is a cut, not a fade.
useEffect(() => {
if (!leaving) return;
const id = requestAnimationFrame(() => setFadeOut(true));
@@ -43,218 +44,63 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
}, [leaving]);
const handleEnter = () => {
- if (entering) return;
- if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
- onEnter();
- return;
- }
- setEntering(true);
- // Begin the cross-fade immediately so the water shimmer plays ON the splash as
- // it fades into the revealed content (snappier and more blended than shimmer-
- // then-cut).
+ if (entered.current) return;
+ entered.current = true;
onEnter();
- // Water refraction: ramp the displacement scale up and back down while the
- // turbulence drifts, so the logo ripples like a disturbed liquid surface.
- const DURATION = 800;
- const MAX_SCALE = 40;
- const start = performance.now();
- const tick = (now: number) => {
- const t = Math.min(1, (now - start) / DURATION);
- const env = Math.sin(Math.PI * t); // 0 -> 1 -> 0
- if (dispRef.current) dispRef.current.setAttribute("scale", String(env * MAX_SCALE));
- if (turbRef.current) {
- const f = 0.008 + t * 0.02;
- turbRef.current.setAttribute("baseFrequency", `${f.toFixed(4)} ${(f * 1.3).toFixed(4)}`);
- }
- if (t < 1) requestAnimationFrame(tick);
- };
- requestAnimationFrame(tick);
};
enterRef.current = handleEnter;
- // Scroll intent = enter. Scrolling PAST the splash already dismissed it (the shell's
- // IntersectionObserver), but only after a full screen of scrolling and with a hard
- // cut. First wheel tick / touch drag / scroll key now plays the SAME water-ripple
- // enter as a tap. Listeners are passive (never block the scroll) and the `entering`
- // guard inside handleEnter dedupes against the button path.
+ // The gate: hand over when the morph ends.
useEffect(() => {
if (leaving) return;
- let touchY: number | null = null;
- const onWheel = (e: WheelEvent) => {
- if (e.deltaY > 8) enterRef.current();
- };
- const onTouchStart = (e: TouchEvent) => {
- touchY = e.touches[0]?.clientY ?? null;
- };
- const onTouchMove = (e: TouchEvent) => {
- if (touchY == null) return;
- const y = e.touches[0]?.clientY ?? touchY;
- if (touchY - y > 24) { // a deliberate upward drag (scroll down), not a resting thumb
- touchY = null;
- enterRef.current();
- }
- };
+ const id = window.setTimeout(() => enterRef.current(), SPLASH_MS);
+ return () => window.clearTimeout(id);
+ }, [leaving]);
+
+ // Any intent to move on skips the rest. Passive listeners so none of this can block a
+ // scroll, and handleEnter dedupes against the timer.
+ useEffect(() => {
+ if (leaving) return;
+ const skip = () => enterRef.current();
const onKey = (e: KeyboardEvent) => {
- if (e.key === "ArrowDown" || e.key === "PageDown" || e.key === " ") enterRef.current();
+ if (["ArrowDown", "PageDown", " ", "Escape", "Enter"].includes(e.key)) skip();
};
- window.addEventListener("wheel", onWheel, { passive: true });
- window.addEventListener("touchstart", onTouchStart, { passive: true });
- window.addEventListener("touchmove", onTouchMove, { passive: true });
+ window.addEventListener("wheel", skip, { passive: true });
+ window.addEventListener("touchstart", skip, { passive: true });
+ window.addEventListener("pointerdown", skip, { passive: true });
window.addEventListener("keydown", onKey);
return () => {
- window.removeEventListener("wheel", onWheel);
- window.removeEventListener("touchstart", onTouchStart);
- window.removeEventListener("touchmove", onTouchMove);
+ window.removeEventListener("wheel", skip);
+ window.removeEventListener("touchstart", skip);
+ window.removeEventListener("pointerdown", skip);
window.removeEventListener("keydown", onKey);
};
}, [leaving]);
- // Auto-advance: the splash is an intro, not a gate. After a beat it runs the same
- // water-ripple enter the visitor would have triggered themselves; a click, scroll,
- // or arrow key still fast-forwards it, and handleEnter's `entering` guard dedupes
- // the two paths. Reduced-motion visitors never get here (the shell skips the splash
- // outright), and the once-per-session flag is unchanged.
- useEffect(() => {
- if (leaving) return;
- const id = window.setTimeout(() => enterRef.current(), SPLASH_AUTO_ENTER_MS);
- return () => window.clearTimeout(id);
- }, [leaving]);
-
- // Pre-warm on idle (post-LCP): compile the filter graph + promote the logo layer.
- useEffect(() => {
- const ric: typeof requestIdleCallback | undefined = (window as any).requestIdleCallback;
- const id = ric ? ric(() => setWarmed(true)) : window.setTimeout(() => setWarmed(true), 1500);
- return () => {
- const cic: typeof cancelIdleCallback | undefined = (window as any).cancelIdleCallback;
- if (ric && cic) cic(id as number);
- else window.clearTimeout(id as number);
- };
- }, []);
-
- // The splash is a FIXED overlay from its very first paint, never a block in document
- // flow. It used to be `relative min-h-[100svh]` and only became fixed on the way out,
- // which meant removing it pulled a whole viewport of height out of the flow and shoved
- // the hero up: a layout shift of almost exactly 1.0. A click hid that from CLS (shifts
- // within 500ms of input are excused), but reduced-motion and returning-session
- // visitors, who never click, ate it in full and scored 74. As an overlay it is out of
- // flow already, so dismissing it cannot reflow anything, and the auto-advance costs no
- // CLS either.
return (
-
- {/* Click-to-enter: soft Cherenkov wash. The ripple itself is the SVG water
- filter refracting the logo (driven by handleEnter's rAF loop). */}
- {entering && (
-
- )}
-
- {/* Water-refraction filter, animated by handleEnter */}
-
-
-
-
-
-
-
- {/* Filter pre-warm: a near-invisible 1px paint through the filter (opacity must be
- non-zero or the paint is skipped and nothing compiles). Idle-gated via `warmed`. */}
- {warmed && !entering && (
-
- )}
-
- {/* Logo (click to enter) + pulsing Cherenkov glow */}
-
-
- {/* Plain div, no framer: keeps the logo (the LCP element) a clean,
- statically-painted image. The water filter + zoom only apply on enter. */}
-
- {/* Data URI (no network request) so the logo paints with FCP; it is the
- mobile LCP element. */}
-
-
-
-
-
- {/* Cover text hidden on mobile (logo + corner icons carry it there); fades out on enter */}
-
- Precision Engineering for Everyone
-
-
- A software studio in Kennett Square, PA. Custom e-commerce, apps, games,
- payments, and consulting.
-
-
- {/* Nav buttons in the four corners: icon-only on mobile, labeled pills on larger screens */}
- {navButtons.map((b, i) => (
-
-
- {b.label}
-
- ))}
-
- {/* Enter-the-site affordance */}
-
- Explore
-
-
-
+ {/* The mark is the only content, so it is the LCP candidate: an is a valid
+ LCP element where an inline is not. */}
+
+
+
);
}
diff --git a/apps/web/src/components/landing/home-shell.tsx b/apps/web/src/components/landing/home-shell.tsx
index 8447f9b..cd7dc0f 100644
--- a/apps/web/src/components/landing/home-shell.tsx
+++ b/apps/web/src/components/landing/home-shell.tsx
@@ -26,7 +26,6 @@ export function HomeShell({ children }: { children: ReactNode }) {
// 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);
@@ -141,7 +140,7 @@ export function HomeShell({ children }: { children: ReactNode }) {
setTimeout(() => {
setSplashUp(false);
tryRelease();
- }, 800);
+ }, 300); // the overlay's 260ms cross-fade, plus a frame
};
return (
@@ -149,7 +148,7 @@ export function HomeShell({ children }: { children: ReactNode }) {
- {splashUp && }
+ {splashUp && }
{children}
diff --git a/apps/web/src/components/landing/splash-logo-data.ts b/apps/web/src/components/landing/splash-logo-data.ts
deleted file mode 100644
index 3a6f733..0000000
--- a/apps/web/src/components/landing/splash-logo-data.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// AUTO-GENERATED from packs/such-software/pack.json (such-graphics).
-// Inlined as data URIs so the splash logo (the homepage LCP element) needs no
-// network request and paints with first contentful paint. Two theme variants:
-// the pack's dark hues are the light hues lifted for contrast on near-black,
-// so each ground gets the palette that was tuned for it. Regenerate from the
-// pack if the mark changes; public/images/branding/splash-such.svg mirrors
-// the dark variant for anything that hotlinks it.
-export const SPLASH_LOGO_SRC_DARK =
- "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%20200%22%20role%3D%22img%22%20aria-label%3D%22Such%20Software%22%3E%3Cdefs%3E%3Cfilter%20id%3D%22splash-glow%22%20x%3D%22-30%25%22%20y%3D%22-30%25%22%20width%3D%22160%25%22%20height%3D%22160%25%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221.6%22%20result%3D%22b%22%2F%3E%3CfeMerge%3E%3CfeMergeNode%20in%3D%22b%22%2F%3E%3CfeMergeNode%20in%3D%22SourceGraphic%22%2F%3E%3C%2FfeMerge%3E%3C%2Ffilter%3E%3C%2Fdefs%3E%3Crect%20x%3D%2214%22%20y%3D%2214%22%20width%3D%22172%22%20height%3D%22172%22%20rx%3D%2212%22%20fill%3D%22none%22%20stroke%3D%22%2345c99e%22%20stroke-width%3D%223.5%22%20filter%3D%22url%28%23splash-glow%29%22%20opacity%3D%220.45%22%2F%3E%3Cg%20fill%3D%22none%22%20stroke-width%3D%2214%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M%2078%2C36%20H%2036%20V%2058%20H%2078%20V%2082%20H%2036%22%20stroke%3D%22%2335925c%22%20transform%3D%22translate%283%2C0%29%22%2F%3E%3Cpath%20d%3D%22m%20118%2C36%20v%2034%20c%200%2C12.15%209.85%2C13%2022%2C13%2012.15%2C0%2022%2C-3.1%2022%2C-13%20V%2036%22%20stroke%3D%22%23e8689e%22%20transform%3D%22translate%28-3%2C0%29%22%2F%3E%3Cpath%20d%3D%22M%2078%2C118%20H%2036%20V%20164%20H%2078%22%20stroke%3D%22%234aade0%22%20transform%3D%22translate%283%2C0%29%22%2F%3E%3Cpath%20d%3D%22M%20118%2C118%20V%20164%20M%20162%2C118%20V%20164%20M%20118%2C141%20H%20162%22%20stroke%3D%22%233ad4c6%22%20transform%3D%22translate%28-3%2C0%29%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E";
-export const SPLASH_LOGO_SRC_LIGHT =
- "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%20200%22%20role%3D%22img%22%20aria-label%3D%22Such%20Software%22%3E%3Cdefs%3E%3Cfilter%20id%3D%22splash-glow%22%20x%3D%22-30%25%22%20y%3D%22-30%25%22%20width%3D%22160%25%22%20height%3D%22160%25%22%3E%3CfeGaussianBlur%20stdDeviation%3D%221.6%22%20result%3D%22b%22%2F%3E%3CfeMerge%3E%3CfeMergeNode%20in%3D%22b%22%2F%3E%3CfeMergeNode%20in%3D%22SourceGraphic%22%2F%3E%3C%2FfeMerge%3E%3C%2Ffilter%3E%3C%2Fdefs%3E%3Crect%20x%3D%2214%22%20y%3D%2214%22%20width%3D%22172%22%20height%3D%22172%22%20rx%3D%2212%22%20fill%3D%22none%22%20stroke%3D%22%231a5c38%22%20stroke-width%3D%223.5%22%20filter%3D%22url%28%23splash-glow%29%22%20opacity%3D%220.45%22%2F%3E%3Cg%20fill%3D%22none%22%20stroke-width%3D%2214%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M%2078%2C36%20H%2036%20V%2058%20H%2078%20V%2082%20H%2036%22%20stroke%3D%22%231a5c38%22%20transform%3D%22translate%283%2C0%29%22%2F%3E%3Cpath%20d%3D%22m%20118%2C36%20v%2034%20c%200%2C12.15%209.85%2C13%2022%2C13%2012.15%2C0%2022%2C-3.1%2022%2C-13%20V%2036%22%20stroke%3D%22%23b83670%22%20transform%3D%22translate%28-3%2C0%29%22%2F%3E%3Cpath%20d%3D%22M%2078%2C118%20H%2036%20V%20164%20H%2078%22%20stroke%3D%22%23176a92%22%20transform%3D%22translate%283%2C0%29%22%2F%3E%3Cpath%20d%3D%22M%20118%2C118%20V%20164%20M%20162%2C118%20V%20164%20M%20118%2C141%20H%20162%22%20stroke%3D%22%2312857e%22%20transform%3D%22translate%28-3%2C0%29%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E";