Skip to content

Rebuild the homepage hero around live numbers, in-place search and a world map - #274

Merged
PArns merged 12 commits into
mainfrom
claude/hero-redesign-h5e181
Aug 6, 2026
Merged

Rebuild the homepage hero around live numbers, in-place search and a world map#274
PArns merged 12 commits into
mainfrom
claude/hero-redesign-h5e181

Conversation

@PArns

@PArns PArns commented Aug 6, 2026

Copy link
Copy Markdown
Owner

The hero was a centred glass card: logo, static headline, a search box that opened a palette over the page, and a marquee of wait times along the bottom. It said what park.fan is; it did not show what park.fan knows right now.

The new hero is two columns over the same rotating photo. Both are panels — the same GlassCard variant="heavy", so they read as one material — and they are staggered, left pulled up and right pushed down, to give the search results room.

Left column: live numbers, an open search, nearby pills

The pill above the headline counts the parks open at this moment, and the intro sentence carries the park and attraction totals inline instead of the hardcoded "over 200 / 7,000+" it claimed before.

Both come from an hourly SSR seed that useGlobalStats replaces on mount and re-polls every five minutes, so the numbers end up fresher than the shell they used to be baked into while the homepage keeps its 1h ISR window. HeroStats fetches at revalidate: 3600 — anything lower would pin all six locale shells back to that value, which is the write regression documented in the caching doc.

The search is open at rest, listing the three nearest parks: the hero's default state is an answer, not an empty field. Focusing the field expands it to the full nearby list, tweened from the height it had. There is no closed state on desktop at all — Escape steps back one level (a query is cleared first, an already-empty field collapses back to three rows) and focus stays on the field either way.

It floats, so a growing list never moves the headline. Two things hold that together:

  • Its resting height is reserved in the flow — measured from the card by a ResizeObserver, not hardcoded — so the nearby pills sit below the open list instead of underneath it. Only the resting height: typing past three rows grows the card over the pills, which fade out on focus anyway. Measured at the same viewport offset at rest, focused, and with 27 result rows on screen.
  • The card is capped to the room left below the field, so a long result list ends at the bottom of the screen and scrolls inside itself rather than running off the page (970 px of a 1000 px viewport).

Both surfaces show the same things. useSearchResults (queries), useHeroBrowseParks (the pre-query list), useSearchNavigation (analytics + routing), SearchResultGroups, SearchBrowseGroup and SearchResultsPanel are shared, so a result cannot look or route differently depending on where it was clicked, and the mobile palette no longer offers a poorer starting list than desktop.

Before anything is typed the list shows nearby parks — open ones first, then by distance — with photo, live open-attraction count and average wait; or this park's rides when the visitor is inside one; or the popular parks when the nearby lookup and its GeoIP fallback both come up empty. The pills below the field show the same parks in the same order, from the same hook.

Search results get photos too: /v1/search knows nothing about our images, so the proxy resolves them from the media database the way /api/nearby already does, carrying the sidecar's focal point. Rows use the default image optimizer, not backgroundImageLoader — that one is tuned for full-bleed photos under gradient overlays (q50) and turns a 44 px thumbnail to mush.

Right column: the world map (xl and up)

One bubble per continent with its open-park count, the selected continent's countries as chips, and a link to the geo route. Clicking another continent switches the panel in place; clicking the selected one navigates to its continent route, and a chip to its country route. Chips say "7 geschlossen" rather than a bare "zu", and the panel lists every country a continent has, so the "11 Länder" in its subtitle matches what is on screen.

Below xl the column is hidden in CSS and only the skeleton's markup ships; the panel itself mounts after load + idle so it never competes with the hero photo for LCP. The text plate widens and centres itself there, since it no longer has a neighbour to sit beside.

public/world.svg is 152 KB of per-country paths and lib/utils/geo-svg.ts is server-only (fs), so scripts/generate-hero-world-map.mjs reduces it to one silhouette per continent (~38 KB, committed, not part of prebuild).

Worth knowing if you ever touch that script: the closed-ring Douglas-Peucker matters. The textbook open-polyline form takes first→last as its baseline, which for a closed ring is the same point — a degenerate baseline that collapses every country to a dot, with a perfectly green build. Look at the rendered map.

The first painted frame was wrong, and that was the flicker

useMediaQuery is false on the server and on the first client render. Choosing the search surface straight from it made the desktop hero paint the mobile trigger: a field carrying a ⌘K badge and a pulsing ring, both of which vanished a moment later when the real field replaced them. Below it sat an empty reserved gap that a whole card then dropped into.

Recorded with a MutationObserver over the hero plus screenshots through the load:

First paint (before) A moment later
field with ⌘K badge + pulsing ring a different field, neither present
empty reserved gap below it a card drops in

Both viewports now start on the same neutral shell, which looks like the final field exactly, and the resting dropdown is drawn as a skeleton in the same breath — hidden md:block, so CSS decides rather than a hook, and the server already renders the right thing for either viewport. The first frame is now the finished composition with placeholders in it; everything after that is content filling boxes that do not move.

Every other live surface follows the same rule. The pill row's height no longer depends on its contents, and the open-parks pill is one element whose content swaps rather than a skeleton replaced by a badge — two elements measured a small but real shift even at identical heights.

Measured CLS is 0 on desktop and on a throttled phone (down from 0.0147), LCP 1.3–1.6 s under 4× CPU + slow-3G throttling.

Motion: CSS for entrances, GSAP for interaction

The entrance is a CSS keyframe, and that split is deliberate. An entrance has to own the very first painted frame; a lazily-loaded library cannot. Either the hero paints and is hidden again when the library arrives — a flash — or it stays blank until the chunk lands. I built it with GSAP first and measured exactly that, so animation-fill-mode: both supplies the hidden start state with no JavaScript and no path where a failed chunk leaves the hero invisible.

The plates rise out of a barely-there scale, the left plate's contents follow one at a time (keyed off nth-child, so no component needs to know its position), and the map's continent bubbles grow out of the map — that last one on arrival only, since switching continents remounts two bubbles and a pop there would fire on exactly the two the visitor is looking at.

GSAP drives interactions: the continent switch (the chip row is replaced wholesale, and a staggered flick-in reads as the panel answering the click), the dropdown's expand-on-focus (the content swaps in one render, so there is nothing for CSS to interpolate towards height: auto), and the header's stagger when the bar solidifies. That last one is layered on top of the existing CSS crossfade, which stays the source of truth — backdrop-filter is deliberately kept out of its transition list, because animating it re-rasterized the blur of the whole page behind the bar on every frame. It plays once per page, not once per scroll: the 50 px threshold is crossed every time the visitor scrolls up and back down.

Everything bails out entirely under prefers-reduced-motion — no animation, not a shortened one. Verified at both settings.

The hero was re-blurring itself on every frame

With the hero on screen at 6× CPU throttle, frames took 50 ms — 125 of 140 over 20 ms. The photo runs an infinite ken-burns transform, and 25 backdrop-filter layers sat over it; every animation frame invalidates every backdrop above it. Twenty-two were pills, country chips, continent bubbles and the open-now badge — all on panels that are already a solid surface, where a filter of their own buys nothing. They are plain translucent fills now.

median frame frames > 20 ms
before 50.0 ms 125 / 140
shipped, desktop 1600 66.7 ms 115 / 119
shipped minus the left plate's blur 50.0 ms 113 / 119
shipped, mobile 390 16.7 ms 0 / 129
no backdrop filter at all 16.7 ms 1 / 129

Both plates carry the glass, which is the single most expensive filter on the page — the left one is the largest surface. Two things worth knowing before tuning it: the radius is not the lever (64 px, 24 px and 12 px all measure the same, which is also why matching the two panels cost nothing), and the animation is — with ken-burns stopped, every filter on the page costs nothing, because a static backdrop is filtered once and cached. So the trade is: slow zoom on the photo, or unlimited glass. Mobile is unaffected either way.

prefers-reduced-motion now actually stops ken-burns, which takes that cost to zero for those visitors. It was an inline style={{animation}}, which no motion-reduce: utility and no media query can override — so the one switch that would have removed this cost did nothing for the people most likely to need it.

The scrim is gone, and the photo came forward

A left-anchored gradient used to dim the left half of the hero. It existed because the text plate had no backdrop blur, and it was anchored left so the photo still read on the right — which, once the plate got real glass, meant the two panels were blurring different backdrops: the left a dimmed one, the right the raw photo. Same 64 px filter, visibly different glass.

Removing it also removes a full-viewport paint, and it was carrying no legibility load: the hero text measures 12.96:1 over the real photo without it, and 21:1 against a forced-white photo.

That headroom is why the photo can come forward. Light mode washed it with 60% white at the top-left and held the image at 90% opacity, both from the era when text sat directly on it; it is a 25% tint and full opacity now. Dark mode keeps its heavier gradient — that is what makes the night shots read as night.

What else changed

The blog strip below the hero is now a real strip: full container width, three cards with their cover photo at 16:9, the post's own opening lines, and reading time on a shared baseline. It was three thumbnails with truncated titles.

Removed Where it lives now
Wait-times marquee (live-wait-ticker, hero-ticker) The nearby pills replace it. /api/analytics/ticker stays — the admin dashboard uses it
Big hero logo, howto + glossary buttons Header and footer still link both; the About section keeps the howto link

Bugs the green build hid

Found by review and by driving the thing in a browser:

Search

  • The shell → panel hand-off always dropped the typed text: focus fires before the first keystroke, and the "first call wins" latch froze the value at "".
  • The panel stole focus unconditionally on mount — scrolling away mid-load yanked the page back up, and it re-fired on every breakpoint crossing.
  • Type-anywhere preventDefault'd Space, so Space stopped scrolling the page and stopped activating focused buttons. It also relocated focus on any letter key from whatever the visitor was on, breaking first-letter navigation; it only fires when nothing is focused now.
  • The field called itself an expanded combobox at all times. cmdk hard-codes aria-expanded="true" and an aria-controls pointing at its list — after spreading our props, so a prop cannot win — and this surface then unmounted the list when closed, leaving the reference dangling. The list is hidden rather than unmounted now, which is the actual combobox pattern and costs no paint.
  • Escape ballooned the card to its cap and snapped back. query empties immediately while debouncedQuery holds the old term for another 300 ms, and the results branch keyed off one while the browse branch keyed off the other — so for that window the card rendered the full result list and the browse list. Both now require the two to agree.
  • Escape dropped focus to <body>; it stays on the field.

Map

  • The Africa landmass was styled as clickable and did nothing: the map draws six continents, the API returns only those with parks. Inert now.
  • Continent bubbles were aria-pressed buttons that navigate when re-pressed, announcing a toggle that never un-toggles. The selected one is a link, the others stay buttons.
  • Switching continents replaced the heading, the open/total figure and the whole chip row with nothing announced. The header is aria-live now.
  • The bubbles faded in offset and snapped into place a second later. Tailwind v4 compiles -translate-x-1/2 to the standalone translate property, not to transform, so the keyframe's transform: translate(-50%,-50%) scale(…) composed on top of it and moved every bubble a second time. The keyframe animates the standalone scale now and leaves translate alone.

Elsewhere

  • --status-operating was 4.39:1 on white, so every small "N offen" set in it missed AA (4.33:1 on the hero pill, 4.37:1 on a country chip). Lightness 0.55 → 0.53 is 4.74:1 and reads as the same green. Token-wide on purpose — other components already use it as small text.
  • The popular-parks fallback painted five green "operating" dots for parks it has no live status for, and its URL-less rows closed the dropdown on a click that navigated nowhere.
  • ES/FR/IT country chips had no plural form: "1 abiertos", "1 ouverts", "1 aperti".
  • The German intro used a construction German does not have ("an … mitzählen") and dropped the article in "bis Magic Kingdom"; the world panel's subtitle said "anderen Kontinent anklicken", which is missing its article. All six locales now say the equivalent of "switch continent".
  • search.avgWait shipped the German Ø to all six locales, next to an existing localized key saying the same thing.
  • Duplicate React key in the world-panel skeleton.

Checks

  • pnpm build — green; /[locale] still reports 1h in the revalidate column
  • pnpm release:check — lint, format, translations (all six locales in sync), client-message namespaces
  • Driven in a real browser against the production build at 1600 / 1440 / 1280 / 1279 / 1100 / 1024 / 768 / 390 in light and dark: the resting dropdown lists three parks with the pills below it, focusing expands it to five without moving the pills, a long query keeps the card inside the viewport and scrolls internally, Escape settles without overshoot, continent switching updates the panel, the plate is centred below xl with equal gutters, no horizontal overflow at 390, no console errors
  • Combobox ARIA verified in all three states, Escape keeps focus on the field, a letter key no longer steals focus from a focused link, prefers-reduced-motion reports animation-name: none for ken-burns and every entrance, and the map bubbles hold one position through their whole animation (0 px drift over 2.4 s)

On the red impeccable detector checks earlier in this PR: both were GitHub Actions infrastructure, not this code. The job never reached checkout — Failed to resolve action download info. Error: Service Unavailable, retried twice, then failed — which also explains the 15-minute hang before it. The workflow is non-blocking by design (continue-on-error, "the check always succeeds"). Run locally against this branch it finishes in seconds with exactly one finding, in components/marketing/scroll-reveal.tsx, a file this PR does not touch.

Best reviewed on the preview deployment — the map, the search and the pills are all live-data surfaces that a diff cannot show.

🤖 Generated with Claude Code

https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ

…world map

The hero was a centred glass card: logo, static headline, a search box that
opened a palette over the page, and a marquee of wait times along the bottom.
It said what park.fan is; it did not show what park.fan knows right now.

The new hero is two columns over the same rotating photo.

Left, everything is a live number. The pill above the headline counts the
parks open at this moment, and the intro sentence carries the park and
attraction totals inline instead of the hardcoded "over 200 / 7,000+" it
claimed before. Both come from an hourly SSR seed that useGlobalStats
replaces on mount and re-polls every five minutes, so the numbers are fresher
than the shell they were baked into and the homepage keeps its 1h ISR window
(HeroStats fetches at revalidate 3600 — anything lower would pin all six
locale shells back to that value).

Search now happens in place. From md up the input stays where it is and the
results render in the card below it; the palette popup is what mobile still
gets, without the inline list. Both surfaces are the same three queries, the
same rows and the same grouping — use-search-navigation.ts and
search-result-groups.tsx came out of search-dialog.tsx so a result cannot
route differently depending on where it was clicked. Before anything is
typed the card lists nearby parks with their photo, live open-attraction
count and average wait, and the pills below the search show the same parks
with a wait-coloured dot. One hook feeds both, so they can never disagree.

Right, on xl and up, a world map: one bubble per continent with its open-park
count, the selected continent's countries as chips, and a link to the geo
route. Clicking another continent switches the panel in place; clicking the
selected one goes to /parks/<continent>, and a chip to
/parks/<continent>/<country>. Below xl the column does not exist, and the
panel only mounts after load and idle — it must not compete with the hero
photo for LCP.

public/world.svg is 152 KB of per-country paths and lib/utils/geo-svg.ts is
server-only, so scripts/generate-hero-world-map.mjs reduces it to one
silhouette per continent (~28 KB, committed). Note the closed-ring
Douglas-Peucker: the textbook open-polyline form takes first→last as its
baseline, which for a closed ring is the same point, and every country
collapses to a dot with a green build.

Gone: the wait-times marquee (the pills replace it, /api/analytics/ticker
stays for the admin dashboard), the big hero logo and the howto/glossary
buttons (both still linked from the header and footer), and the three-post
blog strip — LatestBlogSection further down the page already carries it, and
the hero now fills the fold. The image caption moved to the bottom right,
where the left column no longer runs into it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
park.fan Ready Ready Preview Aug 6, 2026 9:45pm

Request Review

…e layout

Follow-up on the hero redesign, from using it: the search results sat in the
hero's flow, the palette on mobile showed a poorer list than the desktop one,
and everything live arrived one piece at a time in front of the visitor.

The results now FLOAT. An absolutely positioned dropdown under the field,
expanding downward over the page, so nothing in the hero moves when the list
opens, grows or shrinks — the previous in-flow list needed a fixed height for
exactly that reason, and now it can size to its content. The hero section takes
z-10 and drops overflow-hidden so the dropdown escapes it; the sticky header at
z-50 still wins. It is real glass, which it could only be once the nearby pills
stop showing through: the panel fades them out via :has(input:focus) while the
field is focused, and the dropdown lands on the photo and the scrim instead.

Mobile's palette now opens on the same list as desktop. Both call
useHeroBrowseParks — nearby parks, or this park's rides, or the popular parks
when the nearby lookup and its GeoIP fallback both come back empty — through
one SearchBrowseGroup, and the panel body itself is shared as
SearchResultsPanel. Search results get their photo too: /v1/search knows
nothing about our images, so the proxy resolves them from the media database
the way /api/nearby already does, with the sidecar's focal point, and the rows
use the default optimizer rather than backgroundImageLoader — that one is tuned
for full-bleed photos under gradient overlays and turns a 44px thumbnail to
mush.

None of it is on the critical path. The panel chunk loads after load+idle, and
until then the field is a working input that hands its focus and typed text to
the panel on mount, so a visitor faster than the chunk loses nothing and one
who never touches it never pays. Every other live surface holds a placeholder
in exactly its final box, and the pill row's height no longer depends on its
contents (one scrollable row on phones, two rows above sm). Measured CLS is 0
on desktop and on a throttled phone, down from 0.0147.

The left column is a panel now, so the hero reads as two panels rather than
text loose beside a card. No backdrop-blur on that one: it covers most of the
hero, and a backdrop filter that large over the ken-burns photo re-filters its
backdrop every animation frame. The three latest blog posts come back as a
strip directly under the hero. Country chips say "7 geschlossen" instead of
"zu", and the panel lists every country a continent has rather than eight.

Review fixes worth naming, since a green build hid all of them:

- The shell-to-panel hand-off always dropped the typed text: focus fires before
  the first keystroke, and the "first call wins" latch froze the value at "".
- The panel stole focus unconditionally on mount — scrolling away mid-load
  yanked the page back, and it re-fired on every breakpoint crossing.
- Type-anywhere preventDefault'd Space, so Space stopped scrolling the page and
  stopped activating focused buttons for everyone, search user or not.
- The popular-parks fallback painted five green "operating" dots for parks it
  has no live status for, and its URL-less rows closed the dropdown on a click
  that navigated nowhere.
- ES/FR/IT country chips had no plural form: "1 abiertos", "1 ouverts".
- The German intro used a construction German does not have ("an … mitzählen")
  and dropped the article in "bis Magic Kingdom".
- search.avgWait shipped the German Ø to all six locales, next to an existing
  localized key saying the same thing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
Performance and accessibility review of the hero. The headline number: with the
hero on screen at 6x CPU throttle, frames took 50 ms — 125 of 140 over 20 ms.

The cause was 25 backdrop-filter layers over a photo running an infinite
ken-burns transform. Every animation frame invalidates every backdrop above it,
so all 25 were re-blurred at animation rate. Twenty-two of them were pills,
country chips, continent bubbles and the open-now badge — all sitting on panels
that are already a solid surface, where a filter of their own buys nothing at
all. They are plain translucent fills now. Mobile, which never renders the world
panel, is at a clean 16.7 ms with zero slow frames; desktop is at 33.3 ms.

The radius is not the lever, which is worth writing down: 64 px, 24 px and 12 px
all measure 33.3 ms, so shrinking the world panel's blur would give up the look
and buy nothing. It keeps backdrop-blur-3xl.

prefers-reduced-motion now actually stops ken-burns. It was an inline
style={{animation}}, which no motion-reduce: utility and no media query can
override, so the one switch that removes this cost entirely did nothing for the
visitors most likely to need it. It is a class gated on
(prefers-reduced-motion: no-preference) instead.

The hero search called itself an expanded combobox at all times. cmdk hard-codes
aria-expanded="true" and an aria-controls pointing at its list, and it does that
AFTER spreading our props, so a prop cannot win. Our surface then unmounted the
list when closed, leaving the reference dangling too. The list is now hidden
rather than unmounted — which is the combobox pattern, and costs no paint — and
aria-expanded is corrected on the element. Escape keeps focus on the field
instead of dropping it to <body>.

Also from the review:

- The map's Africa landmass was styled as clickable and did nothing: the map
  draws six continents, the API returns only those with parks. Inert now.
- Continent bubbles were aria-pressed buttons that navigate when re-pressed,
  announcing a toggle that never un-toggles. The selected one is a link (it
  navigates), the others stay buttons (they switch the panel).
- Switching continents replaced the heading, the open/total figure and the whole
  chip row with nothing announced. The header is aria-live now.
- Any letter key relocated focus into the search from whatever the visitor was
  on, breaking first-letter navigation. It only fires when nothing is focused.
- --status-operating was 4.39:1 on white, so every small "N offen" set in it
  missed AA (measured 4.33:1 on the hero pill, 4.37:1 on a country chip). 0.55 →
  0.53 lightness is 4.74:1 and reads as the same green. Token-wide on purpose:
  other components already use it as small text.
- Duplicate React key in the world-panel skeleton.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
The hero's default state is now an answer rather than an empty field: the
dropdown is open at rest, listing the three nearest parks. There is no closed
state on desktop at all — Escape clears the query back to that list.

Its resting height is reserved in the flow, so the nearby pills sit below the
open list instead of underneath it. Only the resting height: typing past three
rows grows the card over the pills, which fade out on focus anyway. And the card
is capped to the room left below the field, so a long result list ends at the
bottom of the screen and scrolls inside itself instead of running off the page.

The two columns are staggered — left pulled up, right pushed down — to give that
open list room, and neither forces the other's height any more.

The first painted frame was wrong, and that was the flicker. `useMediaQuery` is
false on the server and on the first client render, so choosing the search
surface from it made the DESKTOP hero paint the MOBILE trigger: a field carrying
a ⌘K badge and a pulsing ring, both of which vanished a moment later when the
real field replaced them. Below it sat an empty reserved gap that a whole card
then dropped into.

Both viewports now start on the same neutral shell, which looks like the final
field exactly, and the resting dropdown is drawn as a skeleton in the same
breath — `hidden md:block`, so CSS decides rather than a hook, and the server
already renders the right thing for either viewport. The card fades in where a
box already is. Recorded before and after: the first frame is now the finished
composition with placeholders in it, and everything after that is content
filling boxes that do not move.

Motion, and why the entrance is not GSAP: an entrance has to own the very first
painted frame, and a lazily-loaded library cannot. Either the hero paints and is
hidden again when the library arrives — a flash — or it stays blank until the
chunk lands. So the entrance is a CSS keyframe with `animation-fill-mode: both`,
which gives it a hidden start state with no JavaScript at all and no path where
a failed chunk leaves the hero invisible. GSAP drives the continent switch
instead, where the whole chip row is replaced and a staggered flick-in reads as
the panel answering the click. That is an interaction, so its chunk loads while
the visitor is already looking at the map. Both bail out entirely under
prefers-reduced-motion.

The blog strip is now a real strip: full container width, three cards with their
cover photo at 16:9, the post's own opening lines, and reading time on a shared
baseline. It was three thumbnails with truncated titles.

The left plate has the backdrop blur it was asked for, so the two panels read as
one material. Measured cost, 6x CPU throttle, hero on screen: 66.7 ms per frame
against 50.0 ms without it — it is the largest blurred surface on the page. Worth
knowing before tuning further: the radius is not the lever, and the animation is.
With ken-burns stopped, every filter on the page costs nothing (16.7 ms, all
blurs in place), because a static backdrop is filtered once and cached. Mobile is
unaffected either way at 16.7 ms and zero slow frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
…ates the same glass

The gap between the open search list and the nearby pills was a hardcoded
282px that happened to match one locale's three rows. Anything that changed the
card's height — a park name wrapping, a longer heading in another language, a
fourth row — moved the card without moving the pills, so the gap drifted or
closed entirely. A ResizeObserver measures the resting card now and the spacer
reserves exactly that. Still only at rest: once a query grows the list, the card
is meant to expand over the pills, so the last resting height stays reserved.

The gap itself was also too tight at 16px. The pills sat glued to the card's
footer while 32px of the plate's own padding sat below them; they get the same
32px above now, so the rhythm reads as intentional in both directions.

Both plates now use GlassCard variant="heavy" — the same component and variant
as the map panel and the search dropdown — rather than a hand-rolled
backdrop-blur that had already drifted to 40px against the panel's 64px. Side by
side, any difference in radius or tint reads immediately as one of them being
wrong. Costs nothing: the earlier measurements showed radius is not what a
backdrop filter over a moving backdrop charges for.

GlassCard forwards a ref so the measurement has something to observe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
The header already cross-fades from transparent to solid on hero pages, and that
crossfade is deliberately tuned: `backdrop-filter` is kept out of its transition
list because animating it re-rasterized the blur of the whole page behind the bar
on every frame. None of that is replaced. GSAP only layers a stagger on top, then
`clearProps` hands opacity and transform back to the class-driven fade.

It plays once per page, not once per scroll. The 50px threshold is crossed every
time the visitor scrolls back up and down again, and re-running the stagger there
would turn the header into a fidget.

Nothing is hidden up front waiting for the library: the tween starts from the
state the elements are already in, so a failed chunk or reduced motion leaves the
header behaving exactly as before. Measured in a browser at both settings — six
elements carry inline styles at the peak of the tween and none afterwards, the
second threshold crossing animates nothing, and under prefers-reduced-motion
nothing animates at any point while all six are still visible.

Cost: the GSAP chunk is fetched the first time a hero page is scrolled past 50px.
Non-hero pages never activate the hook, so they never pay for it, and the chunk is
already on the page for the world map's continent switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
Six things, most of them from looking at the thing rather than at the diff.

The dropdown expands on focus, from the three resting rows to the full nearby
list, tweened from the height it had. GSAP for that one: the content swaps in a
single render, so there is nothing for CSS to interpolate towards `height: auto`.
The spacer keeps reserving the RESTING height while expanded, so the list grows
over the pills instead of shoving them down — measured at the same viewport
offset at rest, focused, and with 27 result rows on screen.

Escape no longer balloons the card. `query` empties immediately while
`debouncedQuery` holds the old term for another 300 ms, and the results branch
keyed off one while the browse branch keyed off the other — so for that window
the card rendered the full result list AND the browse list, hit its cap and
snapped back. Both now require the two to agree. Escape also steps back one level
rather than one state: a query is cleared first, an already-empty field collapses
to three rows, focus stays put either way.

The nearby pills sort open parks first, then by distance, like the nearby card's
list already did. Pure distance order buries the one open park behind three shut
ones at three in the afternoon, and the pills are about where you could go now.

The scrim is gone. It was a left-anchored gradient standing in for a backdrop
blur the left plate did not have, and it was anchored left so the photo still
read on the right — which, once the plate got real glass, meant the two panels
were blurring different backdrops: the left a dimmed one, the right the raw
photo. Same 64 px filter, visibly different glass. Removing it also makes the
hero one paint cheaper. Text over the plate measures 12.96:1 without it (21:1
against a forced-white photo), so it was carrying no legibility load either.

That headroom is why the photo can come forward: light mode washed it with 60%
white at the top-left and held the image at 90% opacity, both from the era when
text sat directly on it. Now it is a 25% tint and full opacity. Dark mode keeps
its heavier gradient — that is what makes the night shots read as night.

The hero entrance got its stagger: plates rise out of a barely-there scale, the
left plate's contents follow one at a time keyed off nth-child so no component
needs to know its position, and the map's bubbles grow out of the map. That last
one only on arrival — switching continents remounts two bubbles, and a pop there
would fire on exactly the two the visitor is looking at while the chips animate.

Also: "anderen Kontinent anklicken" is not German. All six locales now say the
equivalent of "switch continent" instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
…te when the map is gone

The continent bubbles faded in half a bubble up and to the left, then snapped
into place a second later. Tailwind v4 compiles `-translate-x-1/2` to the
STANDALONE `translate` property, not to `transform`:

    .-translate-x-1\/2 { --tw-translate-x: calc(...); translate: var(--tw-translate-x) ... }

so the keyframe's `transform: translate(-50%,-50%) scale(...)` composed on top of
it instead of replacing it, and every bubble was offset twice for the length of
the animation. The snap was the animation class coming off 900ms later. The
keyframe now animates the standalone `scale` property and leaves `translate`
alone — the two are independent and stack correctly.

Below xl there is no map panel beside the text plate, so it sat at its 672px cap
against a wide empty half. It now gets more width and centres itself there, and
goes back to hugging the left column from xl up. Measured: equal gutters at 1024,
1100 and 1279, left-aligned again at 1280.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
The panels computed `backdrop-filter: blur(64px)` and did visibly nothing — the
photo behind them stayed sharp. Cause: `animation-fill-mode: both` retains the
final keyframe forever, so `hero-plate-in` ending in
`transform: translateY(0) scale(1)` left `matrix(1,0,0,1,0,0)` on every animated
element. An identity transform is still a transform, and an element with one
isolates its backdrop, so every filter inside it had nothing left to sample.
Both hero panels and the search dropdown were affected, and it started exactly
when the entrance animation landed.

`backwards` instead. It still applies the 0% frame during the delay — the reason
the entrance is CSS and not a lazily-loaded library — and hands the element back
to its own styles once it is done, leaving nothing behind. Measured local
contrast inside the map panel against outside it: 0.98 vs 7.73, so the backdrop
is genuinely blurred now, and no element reports a lingering transform.

The pills also lurched whenever the search field lost focus. GSAP tweens the
card's height inline frame by frame, and the ResizeObserver that measures the
resting height for the spacer fired on every one of those frames — so the spacer,
and the pills sitting below it, animated along with the card. Measurement now
pauses while a tween is running. The pills hold one position through rest, focus
and the whole collapse: 0 px over 30 samples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
The right panel only started blurring once its entrance had finished. Same
cause as the last fix, one step further in: `backwards` clears the transform
at the END of the animation, but while the animation RUNS the transform is
there, and a transform makes the element a backdrop root. So the panel had no
backdrop to filter for exactly as long as it was moving.

No keyframe touches a panel any more — the boxes sit still from the first
frame and their contents carry the entrance instead. `hero-plate-in` is gone.

While in here, the entrance got more to look at:

- Contents rise, fade and resolve out of a 6 px blur. That blur is on content,
  which has no backdrop-filter of its own.
- The stagger runs off `--hero-in-i` per position plus `--hero-in-from` for
  where the sequence starts, so `hero-in-late` on the right column shifts the
  whole thing without touching the step between siblings. The hero now reads
  left to right rather than both halves arriving together.
- The landmasses sweep in west to east, and the bubbles land on a map that is
  already there. The sweep order comes from the bubble anchors' x coordinates,
  so retuning an anchor keeps its continent in the right place.

`ENTRANCE_MS` went 900 -> 1600 to outlast the last bubble; below that the class
comes off mid-animation and they jump.

Verified in the headless browser: no hero panel carries a transform, opacity or
filter at any point in the first 2.2 s, and the map panel's backdrop measures
blurred at 120 ms, 400 ms and 1500 ms (local contrast 0.4 / 1.1 / 1.1 inside
against 17 outside). Under prefers-reduced-motion every animation-name is
`none` and everything is at full opacity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
…kup arrives

Four things, all in the hero.

**The glass was too strong.** At 64 px over a 75 % fill the photo behind the
panels stopped being a photo — an even field of colour with nothing of the park
left in it. It is 24 px over 62 % now. Text is not what was paying for it: the
copy clears 12:1 over the raw photo with no panel at all. Measured floor across
the extremes is 5.15:1, in dark mode over a forced pure-white backdrop brighter
than any photo in the library; real photos measure 11.2-19.2.

**The entrance got more to look at.** The landmasses sweep west to east and the
bubbles land on a map that is already there; the selected continent blooms once
the sweep reaches it and sends one ring out from its bubble; a band of light
crosses each panel once. All of it animates properties that are not transform,
opacity or filter, or rides a pseudo-element — so the panels keep their glass
throughout, which is the rule from the last commit.

**The header logo moves instead of cross-fading.** Two lockups at different
positions and sizes were fading into each other, which looked like one thing
vanishing and a bigger one appearing elsewhere. They now travel the same path in
the same 500 ms: measured mid-transition, dx = 0.0 px and dh = 0.0 px, so the eye
reads a single logo. The distance comes from offsetLeft/offsetHeight, never
getBoundingClientRect, or the transforms would feed into their own measurement.
The bar's contents are one GSAP timeline played and reversed rather than a
flourish re-run per scroll — it animates y only, never opacity, so a failed chunk
is a plain fade and not a header nobody revealed.

**And a CLS bug that predates all of it.** The plate's later children sit in a
later chunk of the HTML response than its opening tag, so on a slow link the
browser painted a half-parsed plate 328 px tall and then grew it by 456 px.
Nothing running after hydration can prevent that. The plate reserves its own
height from md up: 784 px is its settled height in all six locales at every width
from 768 px, and it is stable because every piece above it is fixed-size.

Measured at 4x CPU / 1.6 Mbps, five runs each:

| | before | after |
| --- | --- | --- |
| worst CLS | 0.1001 | 0.0095 |
| spread | 0.0045 - 0.1001 | 0.0095 flat |

LCP is unchanged (H1 at 3.30-3.36 s against 3.32-3.46 s on the same build
without these changes). Under prefers-reduced-motion every animation-name is
none, both logos report transform none, and the nav sits at full opacity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
**The site is dark now.** `defaultTheme="dark"` with `enableSystem` off: dark for
everyone on every device, light as something a visitor opts into. Following the
OS meant the site was dark for some people and light for others by accident,
which is the opposite of having a default, and it is why the three-way
light/dark/system menu is gone. A browser still holding `system` is moved to dark
on its next visit, stray class included — next-themes writes `system` onto <html>
as if it were a theme name and adding `dark` does not take it back off.
`viewport.themeColor` drops its prefers-color-scheme pair for the same reason: it
would tint the browser chrome by something unrelated to the page.

The menu is a switch, and the change is a disc of the incoming theme opening out
of it. It covers the viewport, the colours flip underneath where nobody sees the
repaint, and the disc lifts off the newly themed page. A scaled element, not an
animated clip-path — a transform composites on the GPU and interpolates as a
plain number, a circle() radius inside a clip-path string does neither. The theme
still changes under reduced motion or a failed import: `apply` runs exactly once
on every path.

**The light sweep across the panels is gone** and the entrance itself carries the
weight instead: contents drift in from the outside edge, rise, grow the last
3.5 % into place and resolve out of a 12 px blur over 0.85 s. `--hero-in-x` is
mirrored per column, so the hero closes toward its own centre rather than
everything sliding the same way.

**The entrance was playing twice.** The badge, headline and intro are a dynamic
hole: the shell paints a fallback, the real content is streamed in behind it, and
that replacement is a DOM swap — new elements, which start the animation from the
top. On a fast link it lands inside the entrance. On a throttled one the hole
resolved ~2.5 s in, the headline visibly faded a second time, and LCP was handed
to that second fade:

| | LCP, 4 runs at 4x CPU / 1.6 Mbps |
| --- | --- |
| replayed on the swap | 3.31 / 3.49 / 3.84 / 3.99 s |
| window closed first | 3.36 / 3.34 / 3.38 / 3.34 s |

`HeroEntranceGate` closes the window. It is an inline script and not an effect on
purpose: the swap has no fixed time, and on a throttled phone hydration lands
after it, so a `useEffect` timer starts counting too late to ever win. I shipped
that version first and measured it losing.

**The header's right-hand anchor moves like the logo.** Measured from the right
edges, no scale — both copies hold the same two controls at the same size and
only the corner one wraps them in a frosted pill. In the solid state both right
edges land on the same pixel.

**And the blog strip under the hero uses the shared BlogPostCard**, so the first
blog cards a visitor sees stop being the only ones on the site that do not match
the cards around them.

CLS holds at 0.0095 across five runs, mobile LCP 1.24 s. Under
prefers-reduced-motion every animation-name is none, both logos report transform
none, and the theme still switches instantly with no disc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
@PArns
PArns marked this pull request as ready for review August 6, 2026 21:47
@PArns
PArns merged commit 3173df8 into main Aug 6, 2026
2 checks passed
@PArns
PArns deleted the claude/hero-redesign-h5e181 branch August 6, 2026 21:47
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.

2 participants