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
Open
BSalaeddin wants to merge 1 commit into
BSalaeddin wants to merge 1 commit into
Conversation
…lient env, mobile Lighthouse CI gate
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 CIlighthousejob), Lighthouse 12.6.1 mobile / simulated throttling, median of 5 runs, on the unmodified branch point:/<img src="/full-logo.png">/pricing<h1>Simple, Transparent Pricing</h1>initial={{ opacity: 0 }}, which framer serialises into the HTML asstyle="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.HeroTicketSceneis alreadynext/dynamic ssr:false, lg+/idle/WebGL-gated and pinned bythree-lazy-boundary.test.ts; phones never fetch it.react-lottie-player/react-type-animation/gsapare declared but never imported (0 bytes shipped).<Image>declared 120×120 and the hero 2000×2000, so the browser reserved a square box that collapsed to a strip on decode; theDoodleslayer (fixedwithout 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.<link rel=preload>per eager image; the wordmark's own preload had nofetchpriority).posthog-js(72 KB) and classiczod+ locale tables (64 KB gz / 262 KB raw), pulled into the browser only bysrc/lib/env.client.tsto validate ten optional strings.Changes
globals.css:caramel-hero-enter/-enter-scale/-fadekeyframes +.hero-enter*classes reproducing the framer entrance (same durations, easing, per-element delays,bothfill);prefers-reduced-motioncollapses 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 framerinitial/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.tsxlogo 196×50 (real aspect; also makes the 2× optimizer variant 384 px, sharper on 3× phones at its ~148 px CSS width);Footer.tsxlogo 140×36.SupportedSection.tsx: store logosfetchPriority="low"(stillloading="eager"+unoptimized, Argos/marquee rationale intact).env.client.ts:zod→zod/mini(same zod 4 core;.parse(),.shape,z.infer,$ZodErrorunchanged; functional API).env.test.ts19/19.lighthouserc.mobile.json+ a secondlhci autorunstep in thelighthousejob; 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 thehome.spec.ts/pages.spec.tsselectors are unaffected by construction.Before / after (local, mobile, median of 5, same machine, same build/env recipe as the CI
lighthousejob)/before<img>/ 7,465 ms/after<img>/ 2,161 ms/pricingbefore<h1>/ 5,342 ms/pricingafter<h1>/ 1,091 msSEO 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)://pricing(The whole delta is the classic-zod chunk, 262 KB raw / 64 KB gz, minus
zod/minifolded 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--forcebecause the turbo cache is shared across worktrees):pnpm lint:oxlintpnpm turbo run lint --force(eslint, both apps)caramel-extension, 0 errors)pnpm turbo run prettier-check --forcepnpm prettier-check:root(covers.github/workflows/checks-app.yml)pnpm --filter caramel-app knippnpm -r run type-checkpnpm --filter caramel-app testcompose-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) andthree-lazy-boundary.test.tswere also run alone: green.pnpm --filter caramel-app buildoutput: standalone)e2e/seo-regression.spec.tsassertions 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, canonicalhttps://grabcaramel.com;/pricing: 200, 1,461 chars (min 1,000), one h1, JSON-LD parses, canonical/pricing.<h1 class="hero-enter …">with noopacity:0in 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 logowidth="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-prneeds the CI Postgres;e2e-pushruns 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
lighthousejob (PR-only): desktop run as before, thenpnpm exec lhci autorun --config=lighthouserc.mobile.json— same 5 URLs, Lighthouse default mobile emulation + simulated throttling, 3 runs,categories:seo ≥ 0.95andaccessibility ≥ 0.9as errors (same as desktop),best-practices ≥ 0.9warn,performance ≥ 0.70warn on the median run (aggregationMethod: median-run; 0.70 = the lowest measured after-median,/pricing0.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 toerroronce CI has a few runs of its own distribution./coupons,/coupons/codecademy.comand/supported-storeswere not measured locally (they need the seeded catalog) and sit under the same warn.Owner questions
posthog-jsafter idle (72 KB gzip on every route; touchesidentity.ts,support-form.tsx,reportUserVisibleFailure.tsand two unit-test mocks) — separate PR with the observability owner?Sentry.lazyLoadIntegration('replayIntegration')(~40 KB gzip deferred; first seconds of a replay lost)?react-lottie-player/react-type-animation/gsap(+@use-gesture/react,react-fast-marquee) frompackage.jsonand knip'signoreDependencies— lockfile-only change, kept out of this PR./and/pricing; promote the mobile perf assertion toerroronce the runner distribution is known.Not verified here: the e2e suites (
e2e-pr,landingvisual regression) — they need the CI Postgres; the assertions they pin (text, h1, JSON-LD, hrefs) are untouched by construction. No browser automation was used.