Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/checks-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ─────────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions apps/caramel-app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
43 changes: 43 additions & 0 deletions apps/caramel-app/lighthouserc.mobile.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
107 changes: 39 additions & 68 deletions apps/caramel-app/src/components/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<div className="flex w-[55%] flex-col items-start text-left lg:w-full lg:items-center lg:text-center">
<motion.h1
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20 }
}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: revealEase }}
className="mb-6 flex flex-col items-start text-5xl font-bold tracking-tight lg:items-center lg:text-4xl md:text-3xl"
>
<motion.div
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20 }
}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: revealEase }}
className="mb-4 text-gray-700 dark:text-white"
>
{/* 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. */}
<h1 className="hero-enter mb-6 flex flex-col items-start text-5xl font-bold tracking-tight lg:items-center lg:text-4xl md:text-3xl">
<div className="mb-4 text-gray-700 dark:text-white">
Welcome to
</motion.div>
</div>
<motion.div
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.9 }
}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.8,
delay: 0.3,
type: 'spring',
stiffness: 100,
damping: 15,
}}
className="relative"
className="hero-enter-scale relative"
style={{ animationDelay: '0.3s' }}
whileHover={
reduceMotion ? undefined : { scale: 1.02 }
}
>
{/* 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
Expand All @@ -549,28 +539,19 @@ export default function HeroSection() {
<Image
src="/full-logo.png"
alt="Caramel — the open-source coupon extension"
height={2000}
width={2000}
height={467}
width={1830}
className="max-w-md drop-shadow-lg lg:mx-auto lg:w-full"
priority
fetchPriority="high"
unoptimized
/>
</motion.div>
</motion.h1>
</h1>

<motion.p
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: 10 }
}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.6,
delay: 0.4,
ease: revealEase,
}}
className="mb-10 max-w-xl text-xl leading-relaxed text-gray-600 dark:text-gray-300 lg:mx-auto lg:max-w-2xl lg:text-lg md:text-base"
<p
className="hero-enter mb-10 max-w-xl text-xl leading-relaxed text-gray-600 dark:text-gray-300 lg:mx-auto lg:max-w-2xl lg:text-lg md:text-base"
style={{ animationDelay: '0.4s' }}
>
The{' '}
<motion.a
Expand Down Expand Up @@ -621,24 +602,14 @@ export default function HeroSection() {
hijacking creatorsʼ commissions
</motion.a>
.
</motion.p>
</p>

{/* 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. */}
<motion.div
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20 }
}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.6,
delay: 0.8,
ease: revealEase,
}}
className="flex flex-wrap items-center justify-start gap-3 lg:justify-center md:flex-col md:items-center"
<div
className="hero-enter flex flex-wrap items-center justify-start gap-3 lg:justify-center md:flex-col md:items-center"
style={{ animationDelay: '0.8s' }}
>
<motion.a
href="#install-extension"
Expand Down Expand Up @@ -696,7 +667,7 @@ export default function HeroSection() {
>
Why Choose Caramel?
</motion.a>
</motion.div>
</div>
</div>

{/* RIGHT column: on desktop, ONE interactive WebGL box holds
Expand Down
40 changes: 17 additions & 23 deletions apps/caramel-app/src/components/PricingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,40 +57,34 @@ export default function PricingSection() {
</div>

<div className="relative z-10 mx-auto max-w-7xl px-6 lg:px-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="mb-20 mt-32 text-center"
>
{/* 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. */}
<div className="hero-enter mb-20 mt-32 text-center">
{/* 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. */}
<motion.h1
className="mb-6 text-5xl font-bold text-gray-900 dark:text-white lg:text-4xl"
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
<h1
className="hero-enter-scale mb-6 text-5xl font-bold text-gray-900 dark:text-white lg:text-4xl"
style={{ animationDelay: '0.2s' }}
>
Simple, Transparent{' '}
<span className="text-caramel">Pricing</span>
</motion.h1>
<motion.p
className="mx-auto max-w-3xl text-xl text-gray-600 dark:text-gray-300 lg:text-lg"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
</h1>
<p
className="hero-enter mx-auto max-w-3xl text-xl text-gray-600 dark:text-gray-300 lg:text-lg"
style={{ animationDelay: '0.3s' }}
>
No tricks, no hidden fees. Caramel is completely free
and always will be.
</motion.p>
</motion.div>
</p>
</div>

{/* Stats Grid */}
<motion.div
Expand Down
13 changes: 12 additions & 1 deletion apps/caramel-app/src/components/SupportedSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,24 @@ export default function SupportedSection() {
brand PNGs; routing 16 of them
through sharp on every cold
load starves 2-core CI runners
and flakes the nav e2e suite. */}
and flakes the nav e2e suite.
fetchPriority="low" (2026-09-12):
Next emits a <link rel=preload>
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. */}
<Image
src={store.image}
alt={`${store.name} logo`}
width={80}
height={80}
loading="eager"
fetchPriority="low"
unoptimized
className="h-full w-full object-contain"
/>
Expand Down
2 changes: 1 addition & 1 deletion apps/caramel-app/src/layouts/Footer/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default function Footer() {
src="/full-logo.png"
alt="Caramel"
width={140}
height={45}
height={36}
className="brightness-0 invert"
/>
</Link>
Expand Down
11 changes: 9 additions & 2 deletions apps/caramel-app/src/layouts/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<Image
src="/full-logo.png"
alt="Caramel"
height={120}
width={120}
width={196}
height={50}
className="mb-auto mt-auto w-4/5 cursor-pointer sm:w-5/12"
/>
</Link>
Expand Down
Loading
Loading