From 3481c6bcac605e226fa19095ab831ec4d6eb2099 Mon Sep 17 00:00:00 2001 From: Julian <14049295+julianken@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:16:11 -0700 Subject: [PATCH 1/2] feat: load-aware hero spinner + glitch-in reveal (#444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero spinner introduced in #441 was a zero-JS, CSS-only element that animated perpetually behind every thumbnail (raised in the #443 review), and it gave no signal that loading had finished. This makes the hero image-pair load-aware so the spinner reflects real load state and a genuine load is rewarded with a quick glitch-in. - Extract the image+spinner into a small `"use client"` child, `HeroImagePair`, rendered by `ThemeAwareHero`. The server component keeps ownership of the Media null-guard, `toRelativeSrc`, focal-point math, and the aspect-ratio wrapper, so the public props and both call sites (PostCard, posts/[slug]) are unchanged. - Cached case: a `useLayoutEffect` probes `img.complete` on the visible variant synchronously before paint (mirroring FadeReveal), so a warm image appears instantly — no spinner flash, no glitch. - Real-load case: the spinner shows while the visible variant streams in; its `onLoad` removes the spinner and applies the existing `.glitch-reveal` animation (reused, not reinvented) for the reveal. The hidden variant's onLoad is ignored so the visible variant drives the state. - `prefers-reduced-motion: reduce` is honored by the global media query in globals.css, which collapses `.glitch-reveal` to ~0ms: the image just appears, spinner still allowed. - The glitch class lives on a theme-agnostic `absolute inset-0` wrapper holding both variants — it reads correctly in light and dark, provides the positioning context the `fill` images need, and adds no layout shift. The variants stay free of opacity/transition classes so the View Transitions theme crossfade (#53) is not degraded. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/HeroImagePair.tsx | 154 ++++++++++++++++++ src/components/ThemeAwareHero.tsx | 42 ++--- tests/unit/components/ThemeAwareHero.test.tsx | 53 +++++- 3 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 src/components/HeroImagePair.tsx diff --git a/src/components/HeroImagePair.tsx b/src/components/HeroImagePair.tsx new file mode 100644 index 0000000..c5e4fa9 --- /dev/null +++ b/src/components/HeroImagePair.tsx @@ -0,0 +1,154 @@ +"use client"; + +import Image from "next/image"; +import { useLayoutEffect, useRef, useState, type CSSProperties } from "react"; + +interface HeroImagePairProps { + lightSrc: string; + darkSrc: string; + lightAlt: string; + darkAlt: string; + sizes: string; + /** objectPosition style for both variants, or undefined for the 50/50 default. */ + imgStyle?: CSSProperties; +} + +function rand(min: number, max: number) { + return Math.round((min + Math.random() * (max - min)) * 10) / 10; +} + +/** + * Returns the per-instance CSS custom properties that drive the shared + * `.glitch-reveal` keyframes (see globals.css). Mirrors FadeReveal so each + * hero reveal jitters slightly differently instead of marching in lockstep. + */ +function glitchVars(): CSSProperties { + return { + "--gr-jitter-1": `${rand(0.5, 1.2)}px`, + "--gr-jitter-2": `${rand(-0.8, -0.3)}px`, + "--gr-jitter-3": `${rand(0.1, 0.5)}px`, + "--gr-mid-opacity": `${rand(0.5, 0.75)}`, + animationDuration: `${rand(280, 360)}ms`, + } as CSSProperties; +} + +/** + * Load-aware hero image pair. Renders the two stacked theme variants + * (light visible in light mode, dark visible in dark mode) and manages a + * loading spinner + glitch-in reveal: + * + * - Cached case: a layout effect detects `img.complete` synchronously on + * mount (before the browser paints), so a warm image appears INSTANTLY — + * no spinner flash, no glitch. + * - Real-load case: the spinner shows while the visible variant streams in; + * that variant's `onLoad` removes the spinner and applies `.glitch-reveal` + * for a quick glitch-in. + * + * `prefers-reduced-motion: reduce` is honored by the global media query in + * globals.css, which collapses every animation (including `.glitch-reveal`) + * to ~0ms — the image simply appears, with the spinner still allowed. + * + * The reveal class lives on a theme-agnostic inner wrapper that holds BOTH + * variants, so the glitch reads correctly whichever variant the theme is + * currently displaying. We deliberately keep the variants themselves free of + * any opacity / transition classes so the View Transitions crossfade on the + * site-wide theme toggle is not degraded (see issue #53). + */ +export function HeroImagePair({ + lightSrc, + darkSrc, + lightAlt, + darkAlt, + sizes, + imgStyle, +}: HeroImagePairProps) { + const innerRef = useRef(null); + // `loaded` gates the spinner; `glitch` is true only after a genuine load + // (never for a cached image). Both start false so the SSR/first paint shows + // the spinner; the layout effect flips them before paint for warm images. + const [state, setState] = useState<{ + loaded: boolean; + glitch: boolean; + vars: CSSProperties; + }>({ loaded: false, glitch: false, vars: {} }); + + /** The currently displayed variant (the other is `display: none`). */ + function visibleImg(): HTMLImageElement | null { + const imgs = innerRef.current?.querySelectorAll("img"); + if (!imgs) return null; + for (const img of imgs) { + if (window.getComputedStyle(img).display !== "none") { + return img as HTMLImageElement; + } + } + return null; + } + + useLayoutEffect(() => { + // Synchronous, pre-paint cache check: if the visible variant is already + // decoded, reveal it instantly with no spinner and no glitch. + const img = visibleImg(); + if (img?.complete && img.naturalWidth > 0) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setState({ loaded: true, glitch: false, vars: {} }); + } + // Empty deps: this is a one-shot mount probe, mirroring FadeReveal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function handleLoad(e: React.SyntheticEvent) { + // Only the visible variant drives the reveal; the hidden (display:none) + // variant still fires onLoad as it streams, but it must not flip state. + if (window.getComputedStyle(e.currentTarget).display === "none") return; + setState((prev) => { + if (prev.loaded) return prev; + // Reaching here means the spinner was actually shown (not cached at + // mount), so this is a genuine load → glitch-in. + return { loaded: true, glitch: true, vars: glitchVars() }; + }); + } + + return ( + <> + {/* Centered loading spinner, shown only while the visible variant loads. */} + {!state.loaded && ( +