Skip to content

perf(mobile): paint the hero at first frame, fix logo CLS, zod/mini client env, mobile Lighthouse CI gate - #237

Open
BSalaeddin wants to merge 1 commit into
mainfrom
seo/mobile-performance
Open

BSalaeddin wants to merge 1 commit into
mainfrom
seo/mobile-performance

Conversation

@BSalaeddin

Copy link
Copy Markdown
Collaborator

What / why

PageSpeed Insights (mobile, 2026-09-11) scored the home page 70 with an 8.6 s LCP and 0.10 CLS (/pricing: 72, LCP 7.2 s) while the desktop preset in CI scores it 90 and only warns at 0.5 — 245 of the last 920 GSC clicks are phones, and CI could not see a mobile regression at all. This PR measures the mobile pages properly, fixes the measured causes without touching copy/metadata/robots/sitemap/coupon logic, and adds a mobile Lighthouse CI step.

Full write-up with method, per-run numbers and chunk attribution: caramel-artifact/seo-2026-09-11/mobile-performance.md (maintainer machine).

Diagnosis (measured, not guessed)

Local production build (next build + next start, same env as the CI lighthouse job), Lighthouse 12.6.1 mobile / simulated throttling, median of 5 runs, on the unmodified branch point:

Page Perf LCP CLS LCP element LCP phases
/ 44 7,927 ms 0.063 hero wordmark <img src="/full-logo.png"> TTFB 725 / load 0 / render delay 7,465 ms
/pricing 55 5,416 ms 0.064 <h1>Simple, Transparent Pricing</h1> TTFB 653 / load 0 / render delay 5,342 ms
  1. The LCP elements were server-rendered invisible. The hero h1, wordmark, lead paragraph and CTAs (and the pricing header) were framer-motion elements with initial={{ opacity: 0 }}, which framer serialises into the HTML as style="opacity:0". Nothing above the fold could paint until the whole 429 KB-gzip first-load bundle had hydrated — the image itself was in the browser at ~0.7 s (load delay/time 0) and painted 7.5 s later.
  2. The 3D hero is not the mobile problem: HeroTicketScene is already next/dynamic ssr:false, lg+/idle/WebGL-gated and pinned by three-lazy-boundary.test.ts; phones never fetch it. react-lottie-player / react-type-animation / gsap are declared but never imported (0 bytes shipped).
  3. CLS: the wordmark PNG is 1830×467 but the header <Image> declared 120×120 and the hero 2000×2000, so the browser reserved a square box that collapsed to a strip on decode; the Doodles layer (fixed without offsets, i.e. static-positioned after the header) moved with it. The one baseline run where the PNG decoded before first layout scored CLS 0.
  4. Nine below-the-fold store logos were preloaded at the same priority as the LCP image (Next emits a <link rel=preload> per eager image; the wordmark's own preload had no fetchpriority).
  5. JS: 429 KB gzip on every route; the two largest avoidable chunks are posthog-js (72 KB) and classic zod + locale tables (64 KB gz / 262 KB raw), pulled into the browser only by src/lib/env.client.ts to validate ten optional strings.

Changes

  • globals.css: caramel-hero-enter / -enter-scale / -fade keyframes + .hero-enter* classes reproducing the framer entrance (same durations, easing, per-element delays, both fill); prefers-reduced-motion collapses them to the opacity fade (now honoured before hydration, which framer could not do).
  • HeroSection.tsx: h1 / wordmark wrapper / paragraph / CTA row use the CSS entrance instead of framer initial/animate (framer stays for hover/tap/loops). Wordmark <Image> declared at its real 1830×467 + fetchPriority="high" (img and preload).
  • PricingSection.tsx: header wrapper / h1 / paragraph — same treatment.
  • Header.tsx logo 196×50 (real aspect; also makes the 2× optimizer variant 384 px, sharper on 3× phones at its ~148 px CSS width); Footer.tsx logo 140×36.
  • SupportedSection.tsx: store logos fetchPriority="low" (still loading="eager" + unoptimized, Argos/marquee rationale intact).
  • env.client.ts: zodzod/mini (same zod 4 core; .parse(), .shape, z.infer, $ZodError unchanged; functional API). env.test.ts 19/19.
  • CI: lighthouserc.mobile.json + a second lhci autorun step in the lighthouse job; both report dirs uploaded; desktop config untouched.

Why it is SEO-safe: no visible copy changes; server HTML still carries every string and exactly one <h1> per route (the wordmark alt still completes the heading); nothing moved behind JS — the opposite, the above-the-fold content is now visible in the server render. e2e/seo-regression.spec.ts (min visible chars, one h1, JSON-LD) and the home.spec.ts / pages.spec.ts selectors are unaffected by construction.

Before / after (local, mobile, median of 5, same machine, same build/env recipe as the CI lighthouse job)

Page Perf (5 runs → median) LCP FCP CLS TBT* LCP element / render delay
/ before 43, 44, 44, 52, 44 → 44 7,927 ms 1,449 ms 0.063 1,360 ms wordmark <img> / 7,465 ms
/ after 70, 80, 78, 76, 86 → 78 2,063 ms 983 ms 0.000 (5/5 runs) 669 ms wordmark <img> / 2,161 ms
/pricing before 55, 56, 55, 52, 69 → 55 5,416 ms 941 ms 0.064 1,065 ms <h1> / 5,342 ms
/pricing after 68, 68, 75, 89, 85 → 75 2,676 ms 943 ms 0.000 1,006 ms <h1> / 1,091 ms

SEO 1.00 / a11y 0.96 (0.94 on pricing) / best-practices 0.96 unchanged before → after. Top main-thread items on / (representative run): script evaluation 5.0 s → 3.6 s, style/layout 2.3 s → 2.2 s, rendering 1.5 s → 1.4 s (13.1 s → 10.7 s total).

An intermediate attempt that kept a CSS fade (opacity 0 → 1) instead of framer's is recorded too: CLS went to 0 but LCP only moved to 5.8 s, because Chrome's paint-timing detector skips elements that first paint at opacity 0 and a compositor opacity animation never repaints them — the hero copy never became an LCP candidate and the metric fell through to the post-hydration stat cards. That is why the committed entrance is transform-only (rise / scale-in at full opacity); the wordmark and h1 are recorded on the first frame.

First-load JS as served (<script src> + modulepreload):

Route Before After Delta
/ 22 chunks, 1,466 KB raw / 429 KB gzip 22 chunks, 1,219 KB raw / 376 KB gzip −247 KB raw / −53 KB gzip (−12%)
/pricing 21 chunks, 1,419 KB raw / 419 KB gzip 21 chunks, 1,173 KB raw / 366 KB gzip −246 KB raw / −53 KB gzip (−13%)

(The whole delta is the classic-zod chunk, 262 KB raw / 64 KB gz, minus zod/mini folded into existing chunks.) Image preloads on /: 10 (wordmark + 9 store logos, no fetchpriority) → 1 (wordmark, fetchpriority="high"). Next 16/Turbopack no longer prints a per-route "First Load JS" table, so these are measured from the served HTML.

Caveat: TBT on this build machine is not comparable to PSI (simulated throttling scales the machine's real CPU time and the box was busy during the "before" runs). LCP/CLS medians and the byte deltas are the honest before/after; the final word is PSI/CrUX on prod after deploy.

Verification

Gates on the final tree (C:\wt-caramel-mobile, worktree of this branch; turbo tasks run with --force because the turbo cache is shared across worktrees):

Gate Result
pnpm lint:oxlint exit 0
pnpm turbo run lint --force (eslint, both apps) exit 0 (3 pre-existing warnings in caramel-extension, 0 errors)
pnpm turbo run prettier-check --force exit 0
pnpm prettier-check:root (covers .github/workflows/checks-app.yml) exit 0
pnpm --filter caramel-app knip exit 0
pnpm -r run type-check exit 0
pnpm --filter caramel-app test 719/719 passed (78 files) at 04:22 on this change set; a re-run at 05:29, while the box sat at 98% CPU from other worktrees' builds, hit the 5 s per-test timeout on three filesystem-walk tests (compose-parity, userInitial, no-raw-coupon-status) — those three pass together in isolation (18/18), and the only edits between the two runs were the transform-only CSS keyframes, one class string and comments. tests/unit/env.test.ts (the client-env contract, 19 tests) and three-lazy-boundary.test.ts were also run alone: green.
pnpm --filter caramel-app build exit 0 (twice: before/after builds; Turbopack, output: standalone)
Crawler contract (e2e/seo-regression.spec.ts assertions re-implemented against the served after-build, release/seo-contract-check.mjs) /: 200, 8,893 visible chars (min 6,000), exactly one <h1> (its accessible name still includes the wordmark alt), 2 JSON-LD blocks parse, no review/aggregateRating markup, canonical https://grabcaramel.com; /pricing: 200, 1,461 chars (min 1,000), one h1, JSON-LD parses, canonical /pricing.
Served HTML checks (after) <h1 class="hero-enter …"> with no opacity:0 in the hero copy; <img … fetchPriority="high" width="1830" height="467" src="/full-logo.png"> + <link rel="preload" as="image" href="/full-logo.png" fetchPriority="high"/>; header logo width="196" height="50"; store logos <img fetchPriority="low" loading="eager" …> and no longer preloaded; /pricing <h1 class="hero-enter-scale …">; served CSS keyframes are transform-only.

Not run here: the Playwright suites (e2e-pr needs the CI Postgres; e2e-push runs against the deployed site) and the desktop/mobile LHCI jobs themselves — CI will. No browser automation via MCP was used; Lighthouse ran headless against Playwright's bundled Chromium.

What CI now covers

lighthouse job (PR-only): desktop run as before, then pnpm exec lhci autorun --config=lighthouserc.mobile.json — same 5 URLs, Lighthouse default mobile emulation + simulated throttling, 3 runs, categories:seo ≥ 0.95 and accessibility ≥ 0.9 as errors (same as desktop), best-practices ≥ 0.9 warn, performance ≥ 0.70 warn on the median run (aggregationMethod: median-run; 0.70 = the lowest measured after-median, /pricing 0.75, minus 0.05, rounded down). Warn, not error, because the score on a shared runner tracks runner contention; the floor makes a regression back to the 44/55 baseline visible in the log and the report artifact, and can be promoted to error once CI has a few runs of its own distribution. /coupons, /coupons/codecademy.com and /supported-stores were not measured locally (they need the seeded catalog) and sit under the same warn.

Owner questions

  1. Lazy-load posthog-js after idle (72 KB gzip on every route; touches identity.ts, support-form.tsx, reportUserVisibleFailure.ts and two unit-test mocks) — separate PR with the observability owner?
  2. Sentry.lazyLoadIntegration('replayIntegration') (~40 KB gzip deferred; first seconds of a replay lost)?
  3. Drop the never-imported react-lottie-player / react-type-animation / gsap (+ @use-gesture/react, react-fast-marquee) from package.json and knip's ignoreDependencies — lockfile-only change, kept out of this PR.
  4. After deploy, re-run PSI mobile on / and /pricing; promote the mobile perf assertion to error once the runner distribution is known.

Not verified here: the e2e suites (e2e-pr, landing visual regression) — they need the CI Postgres; the assertions they pin (text, h1, JSON-LD, hrefs) are untouched by construction. No browser automation was used.

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.

1 participant