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() {
-
+
-
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. */}
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. */}
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;
+ }
+}