Mobile LCP: render splash logo as <img> (valid LCP element); faster blended transition; recenter tracer

- Inline <svg> is not an LCP candidate; on mobile the tagline is hidden so the
  logo was the only large element -> NO_LCP. Serve the mark as
  /images/branding/splash-such.svg and render it via <img> (a valid LCP
  candidate, painted from SSR HTML).
- Transition: start the cross-fade on click (shimmer plays as the splash fades
  into the content) and shorten durations 1.2s -> 0.8s; snappier and blended.
- Tracer target back to the midpoint (computerX+0.5, computerY+0.5).
This commit is contained in:
jwinterm
2026-06-06 18:05:33 -04:00
parent ea012b63fe
commit c85383eff2
3 changed files with 41 additions and 47 deletions
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" role="img" aria-label="Such Software">
<defs>
<filter id="splash-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="1.6" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect x="14" y="14" width="172" height="172" rx="16" fill="none" stroke="#22d3ee" stroke-width="3.5" filter="url(#splash-glow)" opacity="0.45"/>
<g fill="none" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">
<path d="M 78,36 H 36 V 58 H 78 V 82 H 36" stroke="#35c98e"/>
<path d="m 118,36 v 34 c 0,12.15 9.85,14 22,14 12.15,0 22,-1.85 22,-14 V 36" stroke="#e8689e"/>
<path d="M 78,118 H 36 V 164 H 78" stroke="#4aade0"/>
<path d="M 118,118 V 164 M 162,118 V 164 M 118,141 H 162" stroke="#45c99e"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 917 B

+18 -43
View File
@@ -12,42 +12,10 @@ const navButtons = [
{ label: "About", href: "/about", Icon: Info, corner: "right-4 bottom-6 sm:right-6 sm:bottom-24 lg:right-8 lg:bottom-32" },
];
/** The "SUCH" glyph mark (2x2 letter grid) with a Cherenkov-blue treatment. */
function SuchMark() {
return (
<svg
viewBox="0 0 200 200"
role="img"
aria-label="Such Software"
className="h-auto w-[min(72vw,42vh)] drop-shadow-[0_0_40px_rgba(34,211,238,0.25)]"
>
<defs>
<filter id="splash-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="1.6" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* steady bounding box, Cherenkov cyan (static: the circling rings were
removed because stroke-dashoffset is non-composited and destabilized
mobile LCP; the pulsing glow behind the logo carries the motion) */}
<rect x="14" y="14" width="172" height="172" rx="16" fill="none" stroke="#22d3ee" strokeWidth="3.5" filter="url(#splash-glow)" opacity="0.45" />
{/* letters: static so they're a stable LCP candidate */}
<g>
{/* S */}
<path d="M 78,36 H 36 V 58 H 78 V 82 H 36" fill="none" stroke="#35c98e" strokeWidth="14" strokeLinecap="round" strokeLinejoin="round" />
{/* U */}
<path d="m 118,36 v 34 c 0,12.15 9.85,14 22,14 12.15,0 22,-1.85 22,-14 V 36" fill="none" stroke="#e8689e" strokeWidth="14" strokeLinecap="round" strokeLinejoin="round" />
{/* C */}
<path d="M 78,118 H 36 V 164 H 78" fill="none" stroke="#4aade0" strokeWidth="14" strokeLinecap="round" strokeLinejoin="round" />
{/* H */}
<path d="M 118,118 V 164 M 162,118 V 164 M 118,141 H 162" fill="none" stroke="#45c99e" strokeWidth="14" strokeLinecap="round" strokeLinejoin="round" />
</g>
</svg>
);
}
// The "SUCH" mark is served as /images/branding/splash-such.svg and rendered as an
// <img> below: an <img> is a valid Largest-Contentful-Paint candidate, whereas an
// inline <svg> 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 prefersReduced = useReducedMotion();
@@ -71,9 +39,13 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
return;
}
setEntering(true);
// Real 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 = 1200;
// 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).
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) => {
@@ -85,7 +57,6 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
turbRef.current.setAttribute("baseFrequency", `${f.toFixed(4)} ${(f * 1.3).toFixed(4)}`);
}
if (t < 1) requestAnimationFrame(tick);
else onEnter();
};
requestAnimationFrame(tick);
};
@@ -109,7 +80,7 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
style={{ background: "radial-gradient(circle at center, rgba(34,211,238,0.28), transparent 65%)" }}
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.9, 0] }}
transition={{ duration: 1.2, ease: "easeInOut" }}
transition={{ duration: 0.8, ease: "easeInOut" }}
/>
)}
@@ -137,9 +108,13 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
style={{ filter: entering ? "url(#splash-water)" : undefined }}
initial={{ opacity: 1, scale: 1 }}
animate={entering ? { scale: [1, 1.06, 1.14] } : undefined}
transition={entering ? { duration: 1.2, ease: "easeInOut" } : undefined}
transition={entering ? { duration: 0.8, ease: "easeInOut" } : undefined}
>
<SuchMark />
<img
src="/images/branding/splash-such.svg"
alt="Such Software"
className="h-auto w-[min(72vw,42vh)] drop-shadow-[0_0_40px_rgba(34,211,238,0.25)]"
/>
</motion.div>
</button>
+5 -4
View File
@@ -92,10 +92,11 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
// Position calculations
const computerX = position?.x ?? 18;
const computerY = position?.y ?? 18;
// Aim the streamers at the monitor's screen: slightly up-and-left of the icon's
// geometric center. Tweak these two offsets to move the convergence point.
const targetX = computerX - 1;
const targetY = computerY - 1.5;
// Aim the streamers at the monitor's screen. +2.5 hit low-and-right; -1/-1.5 hit
// high-and-left, so this is the midpoint. Tweak these two offsets to move the
// convergence point.
const targetX = computerX + 0.5;
const targetY = computerY + 0.5;
// Check for reduced motion preference
useEffect(() => {