fix: eliminate header UserMenu skeleton layout shift #79

Sammanfogat
supernaut sammanfogade 1 incheckning från fix/78-header-skeleton-cls in i main 2026-07-21 11:11:52 +00:00
Ägare

Root cause

The <UserMenu server:defer /> fallback skeleton (added as #65 M9 to prevent header CLS) itself caused a layout shift when the island streamed in.

The resolved <nav class="user-menu"> is a flex row with align-items: center, gap: var(--spacing-md) and no padding / border / margin / border-radius, so its cross-size is simply its tallest child — an .icon-link whose icon is size="1lh". That makes the resolved menu exactly one line box tall (1lh ≈ 21.3px).

The fallback, however, forced min-block-size: var(--min-touch-target) (3rem = 48px) with placeholder bars of block-size: 1rem. So the reserved box was 48px while the real menu is 21.3px — the header row collapsed by ~27px vertically the moment server:defer swapped the island in.

(The gap already matched --spacing-md, and neither box has padding/border/radius, so height was the sole real mismatch. The language switcher is pinned right by justify-content: space-between, so user-menu width cannot shift it horizontally — width is not a CLS factor here.)

The fix

Match the fallback's outer box to the resolved menu by construction, using shared sizing rather than magic numbers:

  • Drop min-block-size: var(--min-touch-target) (the real nav has none).
  • Size the placeholder bars to block-size: 1lh — the same line box the real .icon-link icons (size="1lh") use. This tracks the font-size shrink at the small breakpoint automatically, since both the real icons and the bars scale with the line box.
  • Keep display: flex; align-items: center; gap: var(--spacing-md) (unchanged — already matched the resolved nav).
  • Bar widths approximate the two anonymous links ("Logga in" + "Skapa konto") so wrapping stays identical; exact width doesn't affect CLS.

Still a subtle placeholder (muted bars at 0.5 opacity); no shimmer animation, so no reduced-motion handling needed.

Anonymous visitors (who see the skeleton to content swap most) render two links — sign in + sign up — matching the two placeholder bars.

Verification (CLS approximately 0)

Measured in headless Chromium (Edge 150) via the DevTools Protocol.

1. Direct box measurement (standalone harness with the real token values):

Box Height Mismatch vs resolved
Resolved .user-menu 21.313px (1lh) —
OLD fallback 48px +26.69px
NEW fallback 21.313px 0px

2. Live page CLS (built server, throttled network so the fallback actually paints before the swap, PerformanceObserver('layout-shift'), 1280x800):

  • OLD build: CLS = 0.0112 (one shift entry)
  • NEW build: CLS = 0 (two consecutive runs)

pnpm check, pnpm lint, pnpm build all clean.

Closes #78

## Root cause The `<UserMenu server:defer />` fallback skeleton (added as #65 M9 to *prevent* header CLS) itself caused a layout shift when the island streamed in. The resolved `<nav class="user-menu">` is a flex row with `align-items: center`, `gap: var(--spacing-md)` and **no padding / border / margin / border-radius**, so its cross-size is simply its tallest child — an `.icon-link` whose icon is `size="1lh"`. That makes the resolved menu exactly **one line box tall (1lh ≈ 21.3px)**. The fallback, however, forced `min-block-size: var(--min-touch-target)` (**3rem = 48px**) with placeholder bars of `block-size: 1rem`. So the reserved box was **48px** while the real menu is **21.3px** — the header row collapsed by ~27px vertically the moment `server:defer` swapped the island in. (The gap already matched `--spacing-md`, and neither box has padding/border/radius, so height was the sole real mismatch. The language switcher is pinned right by `justify-content: space-between`, so user-menu width cannot shift it horizontally — width is not a CLS factor here.) ## The fix Match the fallback's outer box to the resolved menu **by construction**, using shared sizing rather than magic numbers: - Drop `min-block-size: var(--min-touch-target)` (the real nav has none). - Size the placeholder bars to `block-size: 1lh` — the *same* line box the real `.icon-link` icons (`size="1lh"`) use. This tracks the font-size shrink at the small breakpoint automatically, since both the real icons and the bars scale with the line box. - Keep `display: flex; align-items: center; gap: var(--spacing-md)` (unchanged — already matched the resolved nav). - Bar widths approximate the two anonymous links ("Logga in" + "Skapa konto") so wrapping stays identical; exact width doesn't affect CLS. Still a subtle placeholder (muted bars at 0.5 opacity); no shimmer animation, so no reduced-motion handling needed. Anonymous visitors (who see the skeleton to content swap most) render two links — sign in + sign up — matching the two placeholder bars. ## Verification (CLS approximately 0) Measured in headless Chromium (Edge 150) via the DevTools Protocol. **1. Direct box measurement** (standalone harness with the real token values): | Box | Height | Mismatch vs resolved | | --- | --- | --- | | Resolved `.user-menu` | 21.313px (1lh) | — | | OLD fallback | 48px | **+26.69px** | | NEW fallback | 21.313px | **0px** | **2. Live page CLS** (built server, throttled network so the fallback actually paints before the swap, `PerformanceObserver('layout-shift')`, 1280x800): - OLD build: **CLS = 0.0112** (one shift entry) - NEW build: **CLS = 0** (two consecutive runs) `pnpm check`, `pnpm lint`, `pnpm build` all clean. Closes #78
supernaut lade till 1 incheckning 2026-07-21 07:01:18 +00:00
fix(web): eliminate header usermenu skeleton layout shift
Alla kontroller lyckades
ci / ci (pull_request) Successful in 55s
45f82da794
the server:defer fallback reserved a 48px box (min-block-size:
--min-touch-target 3rem) while the resolved .user-menu nav is only 1lh
(~21.3px) tall, so the header row collapsed when the island streamed in.
size the skeleton to 1lh (the same line box the real icon-links use) and
drop the min-block-size so the fallback and resolved boxes match exactly.

closes #78
supernaut tvångsskickade fix/78-header-skeleton-cls från 45f82da794
Alla kontroller lyckades
ci / ci (pull_request) Successful in 55s
till 3f01e985ad
Alla kontroller lyckades
ci / ci (pull_request) Successful in 57s
2026-07-21 11:10:14 +00:00
Jämför
supernaut sammanfogade incheckning 7d83eeb5ac till main 2026-07-21 11:11:52 +00:00
supernaut tog bort grenen fix/78-header-skeleton-cls 2026-07-21 11:11:53 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-07-21 11:11:54 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-08-03 09:41:50 +00:00
Logga in för att delta i denna konversation.
Inga granskare
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
bitborg/bitborg-web!79
Ingen beskrivning angiven.