From 887b59629de84377f9b6630a9d59ed85bb20c3ba Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 17 Aug 2026 12:11:36 -0400 Subject: [PATCH] feat(splash): the pillar signature, and no more flash on refresh Two separate problems, one of them a real bug. THE FLASH. The splash is server-rendered, so on a refresh the browser painted it and only then did hydration read sessionStorage, decide this visitor had already entered, and remove it. That one painted frame is the flash, and React cannot prevent it: even useLayoutEffect runs after the server HTML is painted. So the decision moved before paint. A tiny synchronous script in reads the session flag and prefers-reduced-motion and stamps the root element; a plain CSS rule hides the overlay in the frame it would otherwise appear in. The overlay is `fixed`, so hiding it shifts no layout. Verified with a Playwright run: after reload the root is already stamped at readyState "interactive", the overlay computes to display:none, and the splash is not visible in any of twelve sampled frames. THE SPLASH ITSELF. It is now only the pillar signature: four dots become the four pillar symbols -- commerce, crypto, games, education -- become the word SUCH, then the page takes over. One generated asset (morph_splash*.svg) whose CSS timeline is scaled to land exactly on 1337ms, from such-graphics 628fcc1. SPLASH_MS here and motion.splash.ms there are the same number by construction: a gate that closes early cuts the last letter mid-draw. Everything else on the splash is gone -- tagline, supporting line, the four corner nav links, the chevron, the water-refraction filter and its pre-warm. The corner links were unreachable inside a gate this short anyway, and the header carries them. splash-logo-data.ts goes with them, now orphaned. Perf, measured on a local production build: mobile 97, desktop 98, unchanged from what production scores today. CLS stays ~0. LCP moves 0.9s -> 1.2s on mobile, because the mark is now a 12KB request where the old logo was an inlined data URI. Both theme variants are fetched (3.7KB each gzipped) since `dark:hidden` hides but does not prevent the download, so half of that is waste. Fixable by inlining as data URIs the way splash-logo-data.ts did, at the cost of ~30KB in the bundle; not done here because the score did not move. --- .../public/images/animations/morph_splash.svg | 47 +++ .../images/animations/morph_splash_light.svg | 47 +++ apps/web/src/app/globals.css | 10 +- apps/web/src/app/layout.tsx | 22 ++ .../src/components/landing/hero-splash.tsx | 296 +++++------------- .../web/src/components/landing/home-shell.tsx | 5 +- .../components/landing/splash-logo-data.ts | 11 - 7 files changed, 198 insertions(+), 240 deletions(-) create mode 100644 apps/web/public/images/animations/morph_splash.svg create mode 100644 apps/web/public/images/animations/morph_splash_light.svg delete mode 100644 apps/web/src/components/landing/splash-logo-data.ts 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. + */} +