Skip to content

feat: load-aware hero spinner + glitch-in reveal#445

Merged
mergify[bot] merged 2 commits into
mainfrom
feat/hero-glitch-in-reveal
Jun 1, 2026
Merged

feat: load-aware hero spinner + glitch-in reveal#445
mergify[bot] merged 2 commits into
mainfrom
feat/hero-glitch-in-reveal

Conversation

@julianken

@julianken julianken commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Diagrams

Load-aware state machine for the hero image-pair. ThemeAwareHero (server) keeps the Media guard / toRelativeSrc / focal-point / aspect-ratio wrapper and renders the new HeroImagePair (client), which owns this state:

stateDiagram-v2
    [*] --> Loading: SSR / first paint (loaded=false)

    Loading --> Instant: useLayoutEffect (pre-paint) - visible img.complete and naturalWidth>0
    Loading --> Revealed: visible variant onLoad - spinner was shown, so real load

    state "Instant (cached)" as Instant
    state "Revealed (glitch-in)" as Revealed

    Instant --> [*]: no spinner, no glitch
    Revealed --> [*]: glitch-reveal once, spinner removed

    note right of Loading
        Spinner visible (z-10).
        Hidden display:none variant
        still loads; its onLoad is
        ignored - visible variant
        drives the state.
    end note

    note right of Revealed
        prefers-reduced-motion reduce
        collapses glitch-reveal to ~0ms
        via the global media query in
        globals.css, so image just appears.
    end note
Loading
flowchart LR
    PostCard --> TAH["ThemeAwareHero (server): guard, toRelativeSrc, focal point, aspect-ratio wrapper"]
    Slug["posts/[slug]"] --> TAH
    TAH -->|"lightSrc/darkSrc/alts/sizes/imgStyle"| HIP["HeroImagePair (use client): spinner + onLoad + glitch"]
    HIP --> Light["Image: object-cover dark:hidden"]
    HIP --> Dark["Image: hidden object-cover dark:block"]
Loading

Summary

  • Why: the refactor: remove ASCII/LQIP image-preview experiment, add spinner #441 hero spinner was zero-JS and animated perpetually behind every thumbnail (raised in the refactor: remove ASCII/LQIP image-preview experiment, add spinner #443 review) and never signalled that loading finished. This makes the spinner reflect real load state and rewards a genuine load with a quick glitch-in.
  • Cached vs real load: a useLayoutEffect probes img.complete on the visible variant synchronously before paint (mirroring FadeReveal) — warm images appear instantly with no spinner flash and no glitch. A genuine load shows the spinner, then removes it and applies the existing .glitch-reveal animation (reused, not reinvented).
  • No-regression invariants preserved: both variant classes, fill, fetchPriority="high", sizes, focal-point style, the relative + aspect-ratio wrapper (CLS 0), toRelativeSrc, and the isMediaObject guard are unchanged; the public props and both call sites need no edits. The glitch lives on a theme-agnostic absolute inset-0 wrapper so it reads in light and dark and does not degrade the View Transitions theme crossfade (Theme-aware hero images (light + dark) with View Transitions crossfade #53). prefers-reduced-motion is honored by the global media query.

Screenshots

Pending — post-deploy verification (no per-PR preview env); orchestrator captures the spinner->glitch on a throttled load + cached-instant case after deploy.

Test plan

  • pnpm lint — green (0 errors; 19 pre-existing warnings in unrelated files, 0 in changed files). The review-flagged dead react-hooks/exhaustive-deps disable in HeroImagePair.tsx was removed, so the changed files now lint clean (eslint src/components/HeroImagePair.tsx tests/unit/components/ThemeAwareHero.test.tsx → exit 0, no output).
  • pnpm typecheck (tsc --noEmit) — green
  • pnpm test:unit (vitest) — 666 passed (43 files), incl. new cases: spinner-while-loading, cached → no spinner + no glitch (stubbed complete/naturalWidth), real load → spinner removed + .glitch-reveal applied, and (added in review) the visible/hidden onLoad discrimination — getComputedStyle stubbed so the dark variant reports display:"none": load on the HIDDEN variant is a no-op (spinner stays, no glitch), load on the VISIBLE variant removes the spinner + applies .glitch-reveal. Kept variant/aspect/focal/View-Transitions assertions.
  • pnpm build (throwaway env) — compiled successfully, all 67 routes prerendered
  • pnpm test:e2e — DEFERRED to CI. Local run needs a Postgres-seeded env (pnpm seed:test in global-setup); the throwaway DB has no live server. The kept spec tests/e2e/public/theme-aware-hero.spec.ts is unchanged and CI-enforced (variant swap, no layout shift, no console errors).

Plan reference

Out of plan — follow-up UX refinement to #441's hero spinner.

Closes #444

julianken and others added 2 commits June 1, 2026 11:16
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) <noreply@anthropic.com>
)

The visible/hidden discrimination keys off getComputedStyle(img).display, but
jsdom never applies the Tailwind stylesheet so both variants reported
display:"inline" in tests — the handleLoad early-return guard was never
exercised and visibleImg() passed only by first-element fallthrough. Stub
getComputedStyle so the dark variant reports display:"none" and assert load
on the hidden variant is a no-op while load on the visible variant removes the
spinner + applies .glitch-reveal (verified load-bearing via mutation check).

Also drop the dead react-hooks/exhaustive-deps disable on the useLayoutEffect:
the effect already has an explicit [] dep array, so the directive suppressed
nothing and ESLint flagged it as an unused directive in a changed file. The
adjacent set-state-in-effect disable is kept (legitimate). No runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@julianken-bot julianken-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — load-aware hero spinner + glitch-in reveal

Verdict: APPROVE (1 SUGGESTION). Re-review of head b1d15d2. The two prior SUGGESTIONs are genuinely resolved, verified mechanically (not by trusting the PR's test-plan claims).

Verification ledger (every command run by me this turn, at head b1d15d2)

Check Result
tsc --noEmit exit 0, clean
eslint (3 changed files) exit 0 — 0 warnings in changed files
eslint . (full repo) 0 errors, 19 warnings, none in changed files
vitest run 666 passed / 43 files / 0 failures
Mutation: delete guard HeroImagePair.tsx:101 hidden-variant test FAILS → guard is non-vacuous
Mutation: disable cache probe (line 91 → if (false)) cached-instant test FAILS → probe is non-vacuous
R15 mermaid {"ok": true, "total": 2} — both diagrams render
Worktree diff vs PR identical (3 files, +305/−33); HEAD stable

Resolved 1 — hidden-variant onLoad guard now has teeth. stubDarkHidden() stubs getComputedStyle so the dark variant reports display:"none"; deleting the line-101 early-return makes the hidden-variant test fail. Not a vacuous pass.

Resolved 2 — dead react-hooks/exhaustive-deps disable removed. Scoped lint on the changed files is exit-0, zero output. The remaining set-state-in-effect disable (line 92) is live and mirrors the FadeReveal mount-probe pattern — legitimate.

Findings

SUGGESTION — no-JS / hydration-failure spinner trade-off (HeroImagePair.tsx, see inline). The spinner is now z-10 and removed only by client state, so with JS disabled / on hydration failure it sits over a loaded hero (the old behind-the-image spinner degraded with zero JS). It's pointer-events-none and the repo declares no progressive-enhancement contract, so this is a degraded-state cosmetic trade-off — moving loading state client-side is the whole point of the PR (R9), not a defect. Cheap hardening if ever wanted: render the spinner behind the images so a loaded opaque image covers it without JS.

Out of scope: R13 no-fire; R14 shadow (glitch-reveal rule exists at globals.css:456, hero-spinner pre-existing); screenshots deferred to post-deploy (no preview env), expected; R11 no injection.

Bottom line

Clean re-review — both prior SUGGESTIONs genuinely fixed (mutation-verified), gates green by my own runs, diagrams render. The single new SUGGESTION is an accepted trade-off, not a blocker. APPROVE.

@julianken-bot (opus, fresh context)

{/* Centered loading spinner, shown only while the visible variant loads. */}
{!state.loaded && (
<div
className="hero-spinner pointer-events-none absolute inset-0 z-10 flex items-center justify-center"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION — no-JS / hydration-failure trade-off introduced by this PR.

The previous spinner (base 61510a1) rendered behind both images with no z-index and no JS gate, so a loaded opaque object-cover image painted over it even with zero client JS. This spinner is z-10 (on top) and is removed only by client state ({!state.loaded && ...}), so on a hydration failure or with JS disabled it sits over a fully-loaded hero.

It's pointer-events-none (no interaction block) and the repo declares no progressive-enhancement contract, so this is degraded-state cosmetics, not a blocker — moving loading state client-side is the whole point of a load-aware spinner (reasonable trade-off, R9). Cheap hardening if you ever want the JS-off path: render the spinner behind the images (so the loaded image covers it as a fallback). Flagging so the trade-off is intentional rather than accidental.

@julianken julianken marked this pull request as ready for review June 1, 2026 18:47
@julianken

Copy link
Copy Markdown
Owner Author

@Mergifyio queue

@mergify

mergify Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • 🟠 Waiting for queue conditions
  • ⏳ Enter queue
  • ⏳ Run checks
  • ⏳ Merge
Required conditions to enter a queue
  • -closed [📌 queue requirement]
  • -conflict [📌 queue requirement]
  • -draft [📌 queue requirement]
  • any of [📌 queue -> configuration change requirements]:
    • -mergify-configuration-changed
    • check-success = Configuration changed
  • any of [🔀 queue conditions]:
    • all of [📌 queue conditions of queue rule default]:
      • #approved-reviews-by >= 1
      • #approved-reviews-by >= 1 [🛡 GitHub branch protection]
      • -conflict
      • -draft
      • base = main
      • check-success = Analyze Bundle
      • check-success = CodeQL Analysis
      • check-success = E2E Shard 1/4
      • check-success = E2E Shard 2/4
      • check-success = E2E Shard 3/4
      • check-success = E2E Shard 4/4
      • check-success = ESLint
      • check-success = Next.js Build
      • check-success = TypeScript
      • check-success = Vitest
      • github-review-decision = APPROVED [🛡 GitHub branch protection]
      • any of [🛡 GitHub branch protection]:
        • check-success = ESLint
        • check-neutral = ESLint
        • check-skipped = ESLint
      • any of [🛡 GitHub branch protection]:
        • check-success = TypeScript
        • check-neutral = TypeScript
        • check-skipped = TypeScript
      • any of [🛡 GitHub branch protection]:
        • check-success = Vitest
        • check-neutral = Vitest
        • check-skipped = Vitest
      • any of [🛡 GitHub branch protection]:
        • check-success = Next.js Build
        • check-neutral = Next.js Build
        • check-skipped = Next.js Build
      • any of [🛡 GitHub branch protection]:
        • check-success = Analyze Bundle
        • check-neutral = Analyze Bundle
        • check-skipped = Analyze Bundle
      • any of [🛡 GitHub branch protection]:
        • check-success = CodeQL Analysis
        • check-neutral = CodeQL Analysis
        • check-skipped = CodeQL Analysis
      • any of [🛡 GitHub branch protection]:
        • check-success = E2E Shard 1/4
        • check-neutral = E2E Shard 1/4
        • check-skipped = E2E Shard 1/4
      • any of [🛡 GitHub branch protection]:
        • check-success = E2E Shard 2/4
        • check-neutral = E2E Shard 2/4
        • check-skipped = E2E Shard 2/4
      • any of [🛡 GitHub branch protection]:
        • check-success = E2E Shard 3/4
        • check-neutral = E2E Shard 3/4
        • check-skipped = E2E Shard 3/4
      • any of [🛡 GitHub branch protection]:
        • check-success = E2E Shard 4/4
        • check-neutral = E2E Shard 4/4
        • check-skipped = E2E Shard 4/4

@mergify mergify Bot added the queued label Jun 1, 2026
@mergify

mergify Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • Entered queue2026-06-01 18:50 UTC · Rule: default
  • Checks skipped · PR is already up-to-date
  • Merged2026-06-01 18:51 UTC · at b1d15d215b43a170b11913254ab679357273c464 · squash

This pull request spent 57 seconds in the queue, including 9 seconds running CI.

Required conditions to merge
  • #approved-reviews-by >= 1 [🛡 GitHub branch protection]
  • github-review-decision = APPROVED [🛡 GitHub branch protection]
  • any of [🛡 GitHub branch protection]:
    • check-success = ESLint
    • check-neutral = ESLint
    • check-skipped = ESLint
  • any of [🛡 GitHub branch protection]:
    • check-success = TypeScript
    • check-neutral = TypeScript
    • check-skipped = TypeScript
  • any of [🛡 GitHub branch protection]:
    • check-success = Vitest
    • check-neutral = Vitest
    • check-skipped = Vitest
  • any of [🛡 GitHub branch protection]:
    • check-success = Next.js Build
    • check-neutral = Next.js Build
    • check-skipped = Next.js Build
  • any of [🛡 GitHub branch protection]:
    • check-success = Analyze Bundle
    • check-neutral = Analyze Bundle
    • check-skipped = Analyze Bundle
  • any of [🛡 GitHub branch protection]:
    • check-success = CodeQL Analysis
    • check-neutral = CodeQL Analysis
    • check-skipped = CodeQL Analysis
  • any of [🛡 GitHub branch protection]:
    • check-success = E2E Shard 1/4
    • check-neutral = E2E Shard 1/4
    • check-skipped = E2E Shard 1/4
  • any of [🛡 GitHub branch protection]:
    • check-success = E2E Shard 2/4
    • check-neutral = E2E Shard 2/4
    • check-skipped = E2E Shard 2/4
  • any of [🛡 GitHub branch protection]:
    • check-success = E2E Shard 3/4
    • check-neutral = E2E Shard 3/4
    • check-skipped = E2E Shard 3/4
  • any of [🛡 GitHub branch protection]:
    • check-success = E2E Shard 4/4
    • check-neutral = E2E Shard 4/4
    • check-skipped = E2E Shard 4/4

@mergify mergify Bot merged commit 27c88bd into main Jun 1, 2026
15 checks passed
@mergify mergify Bot deleted the feat/hero-glitch-in-reveal branch June 1, 2026 18:51
@mergify mergify Bot removed the queued label Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: load-aware hero spinner + glitch-in reveal

2 participants