From 697a9eb811242fe7e8683843c63cc84f63e5157a Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 12 Sep 2026 05:35:32 +0100 Subject: [PATCH] perf(mobile): paint the hero at first frame, fix logo CLS, zod/mini client env, mobile Lighthouse CI gate --- .github/workflows/checks-app.yml | 17 ++- apps/caramel-app/.gitignore | 1 + apps/caramel-app/lighthouserc.mobile.json | 43 +++++++ .../src/components/HeroSection.tsx | 107 +++++++----------- .../src/components/PricingSection.tsx | 40 +++---- .../src/components/SupportedSection.tsx | 13 ++- .../caramel-app/src/layouts/Footer/Footer.tsx | 2 +- .../caramel-app/src/layouts/Header/Header.tsx | 11 +- apps/caramel-app/src/lib/env.client.ts | 93 ++++++++------- apps/caramel-app/src/styles/globals.css | 53 +++++++++ 10 files changed, 244 insertions(+), 136 deletions(-) create mode 100644 apps/caramel-app/lighthouserc.mobile.json diff --git a/.github/workflows/checks-app.yml b/.github/workflows/checks-app.yml index 88d60a7f..e1e651d3 100644 --- a/.github/workflows/checks-app.yml +++ b/.github/workflows/checks-app.yml @@ -538,12 +538,27 @@ jobs: working-directory: ./apps/caramel-app run: pnpm lighthouse + # Mobile pass (2026-09-12): the desktop preset above scored the home page + # 90 while PageSpeed's MOBILE lab run scored it 70 with an 8.6 s LCP — + # 245 of the last 920 GSC clicks were phones, and the desktop preset + # cannot see a mobile regression at all. Same URLs, same server, same + # SEO/a11y error budgets; Lighthouse's default mobile emulation + + # simulated throttling, median of 3 runs. Performance is WARN with a + # floor set from the measured post-fix median (see the config) so a real + # regression shows up in the log without letting runner-load variance + # red the job. + - name: Run Lighthouse CI (mobile) + working-directory: ./apps/caramel-app + run: pnpm exec lhci autorun --config=lighthouserc.mobile.json + - name: Upload Lighthouse reports uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: name: lighthouse-reports - path: apps/caramel-app/lighthouse-reports/ + path: | + apps/caramel-app/lighthouse-reports/ + apps/caramel-app/lighthouse-reports-mobile/ retention-days: 14 # ───────────────────────────────────────────────────────────── diff --git a/apps/caramel-app/.gitignore b/apps/caramel-app/.gitignore index 4fb94fb9..933ca664 100644 --- a/apps/caramel-app/.gitignore +++ b/apps/caramel-app/.gitignore @@ -48,3 +48,4 @@ next-env.d.ts # lighthouse ci (checks-app.yml lighthouse job) — local + CI report output /.lighthouseci/ /lighthouse-reports/ +/lighthouse-reports-mobile/ diff --git a/apps/caramel-app/lighthouserc.mobile.json b/apps/caramel-app/lighthouserc.mobile.json new file mode 100644 index 00000000..307d6d84 --- /dev/null +++ b/apps/caramel-app/lighthouserc.mobile.json @@ -0,0 +1,43 @@ +{ + "ci": { + "collect": { + "url": [ + "http://localhost:58000/", + "http://localhost:58000/coupons", + "http://localhost:58000/coupons/codecademy.com", + "http://localhost:58000/supported-stores", + "http://localhost:58000/pricing" + ], + "startServerCommand": "pnpm exec next start -p 58000", + "startServerReadyPattern": "Ready", + "startServerReadyTimeout": 60000, + "numberOfRuns": 3, + "settings": { + "formFactor": "mobile", + "throttlingMethod": "simulate", + "screenEmulation": { + "mobile": true, + "width": 412, + "height": 823, + "deviceScaleFactor": 1.75, + "disabled": false + } + } + }, + "assert": { + "assertions": { + "categories:seo": ["error", { "minScore": 0.95 }], + "categories:accessibility": ["error", { "minScore": 0.9 }], + "categories:best-practices": ["warn", { "minScore": 0.9 }], + "categories:performance": [ + "warn", + { "minScore": 0.7, "aggregationMethod": "median-run" } + ] + } + }, + "upload": { + "target": "filesystem", + "outputDir": "lighthouse-reports-mobile" + } + } +} diff --git a/apps/caramel-app/src/components/HeroSection.tsx b/apps/caramel-app/src/components/HeroSection.tsx index 49bb719f..6932f978 100644 --- a/apps/caramel-app/src/components/HeroSection.tsx +++ b/apps/caramel-app/src/components/HeroSection.tsx @@ -494,53 +494,43 @@ export default function HeroSection() { {/* LEFT column: the hero copy, re-aligned left on desktop and re-centered (as before) below lg. */}
- - + {/* The h1, wordmark, lead paragraph and CTA row enter via + the CSS `hero-enter*` keyframes (globals.css), not + framer `initial`: framer serialised `opacity:0` into + the server HTML, so every LCP candidate stayed + invisible until the full bundle had hydrated — the + 8.6 s mobile LCP PageSpeed measured. Same timings and + easing as before, transform-only (see globals.css for + why the fade had to go); reduced motion is honoured by + the stylesheet. framer stays for hover/tap/loops. */} +

+
Welcome to - +
{/* unoptimized on purpose: the wordmark is a 40KB - PNG, but width=2000 makes the optimizer build a - w=3840 variant ON the LCP critical path — a + PNG, and a large width makes the optimizer build + a w=3840 variant ON the LCP critical path — a cold-cache sharp conversion that starves 2-core CI runners (nav e2e flakes) for zero visual gain. Static serve is instant and cacheable. */} + {/* 1830×467 = the PNG's real pixel size. It was + declared 2000×2000, so until the file decoded + the browser reserved a SQUARE box (~448px tall + on phones) that then collapsed to a 3.9:1 strip + — the largest single layout shift on the page + (PageSpeed mobile CLS 0.10). The CSS width + rules are unchanged, so the rendered size is + identical once loaded. fetchPriority="high" + stamps the img AND its preload so the LCP + image is fetched ahead of the store logos the + page also preloads. */} {/* alt completes the h1: the wordmark IS the rest of the heading, so crawlers (which count img alt inside an h1) read "Welcome to Caramel — the @@ -549,28 +539,19 @@ export default function HeroSection() { Caramel — the open-source coupon extension - +

- The{' '} . - +

{/* CTA Buttons — sized to sit inline on one row on desktop (stacked below md). The stats moved to the coupon cards in the right column. */} - Why Choose Caramel? - +
{/* RIGHT column: on desktop, ONE interactive WebGL box holds diff --git a/apps/caramel-app/src/components/PricingSection.tsx b/apps/caramel-app/src/components/PricingSection.tsx index 9862b06e..ebe020b1 100644 --- a/apps/caramel-app/src/components/PricingSection.tsx +++ b/apps/caramel-app/src/components/PricingSection.tsx @@ -57,40 +57,34 @@ export default function PricingSection() {
- {/* Header */} - + {/* Header — enters via the CSS `hero-enter*` keyframes + (globals.css), not framer `initial`/`whileInView`: this + block is the page's LCP element on phones, and framer's + serialised `opacity:0` kept it invisible until hydration + (Lighthouse mobile: LCP render delay 5.3 s of a 5.4 s LCP). + It sits at the top of the viewport, so "on load" and + "when in view" are the same moment. */} +
{/* h1, not h2: this is /pricing's page title, and that route had no h1 at all. Only PricingPageClient renders this section, so `/` keeps its single hero h1. Tailwind's preflight resets heading size/weight to inherit, so the classes below render this byte-identically to the h2. */} - Simple, Transparent{' '} Pricing - - +

No tricks, no hidden fees. Caramel is completely free and always will be. - - +

+
{/* Stats Grid */} + for each eager image, so these + nine below-the-fold logos were + fetched at the SAME priority as + the hero wordmark (the mobile + LCP image). The attribute lands + on both the img and its preload, + so they still load eagerly, just + behind the LCP image. */} {`${store.name} diff --git a/apps/caramel-app/src/layouts/Footer/Footer.tsx b/apps/caramel-app/src/layouts/Footer/Footer.tsx index f476198a..92d42564 100644 --- a/apps/caramel-app/src/layouts/Footer/Footer.tsx +++ b/apps/caramel-app/src/layouts/Footer/Footer.tsx @@ -80,7 +80,7 @@ export default function Footer() { src="/full-logo.png" alt="Caramel" width={140} - height={45} + height={36} className="brightness-0 invert" /> diff --git a/apps/caramel-app/src/layouts/Header/Header.tsx b/apps/caramel-app/src/layouts/Header/Header.tsx index b87e0ff0..48683e09 100644 --- a/apps/caramel-app/src/layouts/Header/Header.tsx +++ b/apps/caramel-app/src/layouts/Header/Header.tsx @@ -94,11 +94,18 @@ export default function Header({ scrollRef }: HeaderProps) { href="/" className="absolute z-10 ml-5 flex h-full w-[185px] lg:static lg:z-auto lg:ml-0" > + {/* 196×50 = the PNG's real 1830×467 aspect (3.92:1). It was + declared 120×120, so the browser reserved a SQUARE box + that collapsed to a strip when the file decoded — a + layout shift at the very top of every page (part of the + 0.10 mobile CLS PageSpeed measured). Width 196 also makes + the optimizer's 2x variant 384px, i.e. sharp on 3x + phones at the ~148px CSS width it renders at. */} Caramel diff --git a/apps/caramel-app/src/lib/env.client.ts b/apps/caramel-app/src/lib/env.client.ts index 76d3ad70..0923a0dc 100644 --- a/apps/caramel-app/src/lib/env.client.ts +++ b/apps/caramel-app/src/lib/env.client.ts @@ -10,34 +10,45 @@ // // All fields are optional: a missing/bad public var must never crash the // app (required-var fail-fast belongs in env.ts, not here). -import { z } from 'zod' +// +// `zod/mini`, not `zod` (2026-09-12, mobile-performance PR): this module is +// the ONLY zod importer reachable from browser code, and the classic entry +// (method-chained API + every locale's error tables) was landing as a 262 KB +// raw / 64 KB gzip chunk in EVERY route's first-load JS — 15% of the home +// page's JavaScript, to validate ten optional strings. The mini entry is the +// same zod 4 core with a functional, tree-shakeable API; `.parse()`, `.shape` +// and `z.infer` are identical, and the thrown error is the same `$ZodError` +// (message = the JSON issue list, which is what env.test.ts matches on). +// env.ts (server only, never bundled for the browser) keeps classic zod. +import * as z from 'zod/mini' // Base object (keeps `.shape` for CLIENT_ENV_KEYS + `ClientEnv`). The refined // `clientSchema` below adds the cross-field pair checks — mirrors the // serverObjectSchema / serverSchema split in env.ts. const clientObjectSchema = z.object({ - NEXT_PUBLIC_BASE_URL: z.string().min(1).optional(), - NEXT_PUBLIC_SENTRY_DSN: z.string().optional(), - NEXT_PUBLIC_GOOGLE_ANALYTICS_ID: z.string().optional(), - NEXT_PUBLIC_API_ENCRYPTION_ENABLED: z.string().optional(), + NEXT_PUBLIC_BASE_URL: z.optional(z.string().check(z.minLength(1))), + NEXT_PUBLIC_SENTRY_DSN: z.optional(z.string()), + NEXT_PUBLIC_GOOGLE_ANALYTICS_ID: z.optional(z.string()), + NEXT_PUBLIC_API_ENCRYPTION_ENABLED: z.optional(z.string()), // ---- Observability: PostHog dataset routing ------------------------ // Which PostHog project the BROWSER captures target. Mirrors the server's // POSTHOG_DATASET (env.ts) and must agree with it (env.ts fail-fasts on a // mismatch). Defaults to 'disabled' so an unconfigured build never // captures. - NEXT_PUBLIC_POSTHOG_DATASET: z - .enum(['production', 'e2e', 'disabled']) - .default('disabled'), + NEXT_PUBLIC_POSTHOG_DATASET: z._default( + z.enum(['production', 'e2e', 'disabled']), + 'disabled', + ), // production capture pair (project API key + ingestion host). - NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), - NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.optional(z.string()), + NEXT_PUBLIC_POSTHOG_KEY: z.optional(z.string()), // shared E2E test-project capture pair — synthetic Playwright traffic is // ingested here ONLY, keeping it out of the production dataset. - NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_HOST: z.string().optional(), - NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_CAPTURE_TOKEN: z.string().optional(), + NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_HOST: z.optional(z.string()), + NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_CAPTURE_TOKEN: z.optional(z.string()), // Build stamp, injected by next.config.mjs from package.json version // (never set by hand). Falls back to '0.0.0-dev' via APP_VERSION below. - NEXT_PUBLIC_APP_VERSION: z.string().optional(), + NEXT_PUBLIC_APP_VERSION: z.optional(z.string()), }) // A configured dataset must carry its capture pair, or capture would silently @@ -46,33 +57,35 @@ const clientObjectSchema = z.object({ // eager browser singleton below deliberately swallows that (analytics must // never white-screen the app), and the real deploy-time fail-fast lives in // env.ts. -const clientSchema = clientObjectSchema.superRefine((data, ctx) => { - if ( - data.NEXT_PUBLIC_POSTHOG_DATASET === 'production' && - !(data.NEXT_PUBLIC_POSTHOG_HOST && data.NEXT_PUBLIC_POSTHOG_KEY) - ) { - ctx.addIssue({ - code: 'custom', - path: ['NEXT_PUBLIC_POSTHOG_KEY'], - message: - 'NEXT_PUBLIC_POSTHOG_DATASET=production requires both NEXT_PUBLIC_POSTHOG_HOST and NEXT_PUBLIC_POSTHOG_KEY', - }) - } - if ( - data.NEXT_PUBLIC_POSTHOG_DATASET === 'e2e' && - !( - data.NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_HOST && - data.NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_CAPTURE_TOKEN - ) - ) { - ctx.addIssue({ - code: 'custom', - path: ['NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_CAPTURE_TOKEN'], - message: - 'NEXT_PUBLIC_POSTHOG_DATASET=e2e requires both NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_HOST and NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_CAPTURE_TOKEN', - }) - } -}) +const clientSchema = clientObjectSchema.check( + z.superRefine((data, ctx) => { + if ( + data.NEXT_PUBLIC_POSTHOG_DATASET === 'production' && + !(data.NEXT_PUBLIC_POSTHOG_HOST && data.NEXT_PUBLIC_POSTHOG_KEY) + ) { + ctx.addIssue({ + code: 'custom', + path: ['NEXT_PUBLIC_POSTHOG_KEY'], + message: + 'NEXT_PUBLIC_POSTHOG_DATASET=production requires both NEXT_PUBLIC_POSTHOG_HOST and NEXT_PUBLIC_POSTHOG_KEY', + }) + } + if ( + data.NEXT_PUBLIC_POSTHOG_DATASET === 'e2e' && + !( + data.NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_HOST && + data.NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_CAPTURE_TOKEN + ) + ) { + ctx.addIssue({ + code: 'custom', + path: ['NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_CAPTURE_TOKEN'], + message: + 'NEXT_PUBLIC_POSTHOG_DATASET=e2e requires both NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_HOST and NEXT_PUBLIC_POSTHOG_E2E_TEST_PROJECT_CAPTURE_TOKEN', + }) + } + }), +) export type ClientEnv = z.infer diff --git a/apps/caramel-app/src/styles/globals.css b/apps/caramel-app/src/styles/globals.css index 6ce55057..0f0d98f7 100644 --- a/apps/caramel-app/src/styles/globals.css +++ b/apps/caramel-app/src/styles/globals.css @@ -241,3 +241,56 @@ html.dark select:-webkit-autofill:focus { scroll-behavior: auto; } } + +/* Hero entrance (mobile LCP fix, 2026-09-12). The hero copy — the h1, the + wordmark, the lead paragraph and the CTA row — used to enter through + framer-motion `initial={{ opacity: 0 }}`, which framer serialises into the + server HTML as `style="opacity:0"`. Every LCP candidate was therefore + invisible until the whole first-load bundle had downloaded, parsed and + hydrated, and PageSpeed measured the home page's mobile LCP at 8.6 s + (desktop hid it behind a fast CPU). These keyframes keep the same rise / + scale-in motion (same 0.6 s / cubic-bezier(0.22,1,0.36,1) / per-element + delays) as a CSS animation, so the entrance starts at first paint with no + JavaScript on the critical path. + + TRANSFORM ONLY — deliberately no opacity. A first attempt kept the fade + (opacity 0 → 1) and measured LCP barely moved: Chrome's paint-timing + detector skips an element that first paints at opacity 0, and a + compositor-driven opacity animation never repaints it on the main thread, + so the hero copy simply never became an LCP candidate — the metric fell + through to the stat cards that appear after hydration (render delay + 5.3 s). Transform-only means the h1, wordmark and paragraph are painted + (and recorded) on the very first frame and just settle into place. + Reduced motion: no animation at all (the elements are already at rest). */ +@keyframes caramel-hero-enter { + from { + transform: translateY(20px); + } + to { + transform: none; + } +} + +@keyframes caramel-hero-enter-scale { + from { + transform: scale(0.9); + } + to { + transform: none; + } +} + +.hero-enter { + animation: caramel-hero-enter 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.hero-enter-scale { + animation: caramel-hero-enter-scale 0.8s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .hero-enter, + .hero-enter-scale { + animation: none; + } +}