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
18 changes: 14 additions & 4 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ jobs:
run: pnpm --filter @useupup/e2e-test test:e2e:drive-sandbox

Lighthouse:
name: Lighthouse (landing SEO + Best Practices)
name: Lighthouse (landing SEO + Best Practices + mobile perf ratchet)
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
Expand All @@ -384,17 +384,27 @@ jobs:
# lhci boots `next start` itself and asserts per lighthouserc.cjs:
# SEO=100 everywhere; Best Practices 100 on docs pages, ratcheted
# on home/framework pages (third-party embed cookies). A11y is NOT
# asserted here — the axe ratchet above owns it; perf is excluded
# (noisy on shared runners; size-limit owns bundle weight).
# asserted here — the axe ratchet above owns it. Performance is not
# asserted in THIS config either; it has its own mobile run below.
- name: Lighthouse audit + assertions (SEO / Best Practices)
run: pnpm --filter @useupup/landing run lighthouse

# Mobile performance, on its own port and its own config, asserted
# at WARN level only: the score is genuinely noisy on a shared
# runner, so this is a visible ratchet in the log and the uploaded
# report rather than a gate. (`continue-on-error` is banned by the
# test-quality guard and unnecessary — warn assertions never fail.)
- name: Lighthouse mobile performance ratchet (warn-only)
run: pnpm --filter @useupup/landing run lighthouse:mobile

- name: Upload Lighthouse reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: nightly-lighthouse-reports
path: apps/landing/.lighthouseci-reports/
path: |
apps/landing/.lighthouseci-reports/
apps/landing/.lighthouseci-mobile-reports/
if-no-files-found: warn
retention-days: 14

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ apps/e2e-test/screenshots/
# filesystem report output uploaded as a CI artifact
.lighthouseci/
apps/landing/.lighthouseci-reports/
apps/landing/.lighthouseci-mobile-reports/
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,15 @@ DrivePlugin`. All three popup providers now persist a token-expiry key and refre
SEO=100 on every audited page and Best Practices 100 on docs pages /
ratcheted ~0.74 on home+framework pages whose StackBlitz embed + ads tag set
third-party cookies; a11y is deliberately NOT asserted — the axe ratchet
owns it, Lighthouse's a11y audits are axe-core anyway — and perf is excluded
as CI-runner noise; size-limit owns bundle weight), static `build:storybook`
owns it, Lighthouse's a11y audits are axe-core anyway; perf is not asserted
in THIS config. Performance is no longer skipped outright: a second step in
the same job runs `pnpm --filter @useupup/landing run lighthouse:mobile`
(config `apps/landing/lighthouserc.mobile.cjs`: mobile emulation, its own
port 4464 so both configs run back to back, median of 5) and asserts
`categories:performance` at **warn** level — a visible ratchet in the log and
the uploaded report, never a red night, since the score really is noisy on a
shared runner and `continue-on-error` is banned by the test-quality guard;
size-limit still owns bundle weight), static `build:storybook`
for all six frameworks, `smoke:packages`, the mastra LLM evals (only when the
`OPENROUTER_API_KEY` Actions secret exists — absent, the job goes green with
a loud skip notice, never silently), and the **Drive-Sandbox** job — the live
Expand Down
7 changes: 7 additions & 0 deletions apps/e2e-test/landing/thumbs-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ test.describe('Ask AI thumbs feedback', () => {
)
.toBe(true)

// The demo section is client-only and mounts the first time it comes
// within ~400px of the viewport (it is the heaviest thing on the page,
// and on a phone it costs seconds of main-thread time nobody who never
// scrolls to it should pay). Scroll it into view so the Ask-AI panel
// below actually exists.
await page.locator('#demo').scrollIntoViewIfNeeded()

// Ask one short question and wait for the assistant's completed turn.
const panel = page.locator('.upup-ie-ai-panel')
const input = panel.locator('#upup-ai-message')
Expand Down
8 changes: 5 additions & 3 deletions apps/landing/lighthouserc.cjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Nightly Lighthouse gate for the public site — SEO + Best Practices ONLY.
// Accessibility is deliberately not asserted here (the nightly axe ratchet in
// `pnpm run e2e:a11y` owns it — Lighthouse's a11y audits are axe-core anyway),
// and Performance is excluded (score is unusably noisy on shared CI runners;
// bundle weight is already gated by size-limit).
// `pnpm run e2e:a11y` owns it — Lighthouse's a11y audits are axe-core anyway).
// Performance is not asserted in THIS config: it runs alongside, mobile-only
// and warn-only, from `lighthouserc.mobile.cjs` (the score is noisy on shared
// CI runners, so it is a visible ratchet rather than a gate; bundle weight
// stays gated by size-limit).
//
// Run locally after building:
// pnpm exec turbo run build --filter=@useupup/landing
Expand Down
76 changes: 76 additions & 0 deletions apps/landing/lighthouserc.mobile.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Nightly Lighthouse MOBILE PERFORMANCE ratchet for the public site.
//
// Separate from `lighthouserc.cjs` (SEO + Best Practices, desktop defaults)
// because it needs mobile emulation, more runs, and a different port so both
// configs can run back to back in the same job. Every assertion here is `warn`,
// deliberately: the score IS noisy on a shared CI runner, so this exists to make
// a regression visible in the job log and the uploaded report, not to red the
// night. `continue-on-error` is banned by scripts/ci/test-quality-guard.mjs and
// is not needed — a warn-level assertion never fails the run.
//
// Thresholds are the measured post-fix local medians minus 0.05. Raise them when
// the site gets faster; never lower one to make a red go away without saying why.
//
// Run locally after building:
// pnpm exec turbo run build --filter=@useupup/landing
// pnpm --filter @useupup/landing run lighthouse:mobile

const PORT = 4464
const page = path => `http://localhost:${PORT}${path}`

module.exports = {
ci: {
collect: {
// Both marketing shapes (home and a framework page share one
// parameterized layout) plus one docs page, the third template.
url: [page('/'), page('/react/'), page('/docs/getting-started/')],
numberOfRuns: 5,
startServerCommand: `pnpm exec next start -p ${PORT}`,
startServerReadyPattern: 'Ready in',
startServerReadyTimeout: 60000,
settings: {
onlyCategories: ['performance'],
// Default lighthouse emulation is already mobile (Moto G Power
// + simulated 4G); stated here so a future default change
// cannot silently turn this into a desktop run.
formFactor: 'mobile',
screenEmulation: {
mobile: true,
width: 412,
height: 823,
deviceScaleFactor: 1.75,
disabled: false,
},
},
},
assert: {
// Judge the median of the five runs, not the worst one — a single
// runner hiccup must not read as a regression.
aggregationMethod: 'median',
assertMatrix: [
{
// Home + framework pages: the live uploader demo, the
// StackBlitz editor and the scene animations all load
// behind viewport gates, so the initial page is light.
matchingUrlPattern: '^https?://[^/]+/(react/)?$',
// Measured medians on this build: / = 0.74, /react/ = 0.71.
assertions: {
'categories:performance': ['warn', { minScore: 0.66 }],
},
},
{
// Docs pages carry no embeds at all.
matchingUrlPattern: '.*/docs/.*',
// Measured median on this build: 0.73.
assertions: {
'categories:performance': ['warn', { minScore: 0.68 }],
},
},
],
},
upload: {
target: 'filesystem',
outputDir: '.lighthouseci-mobile-reports',
},
},
}
1 change: 1 addition & 0 deletions apps/landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"start": "next start",
"lint": "eslint . --max-warnings 0",
"lighthouse": "lhci autorun --config=lighthouserc.cjs",
"lighthouse:mobile": "lhci autorun --config=lighthouserc.mobile.cjs",
"typecheck": "fumadocs-mdx && tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf .next node_modules/.cache"
Expand Down
4 changes: 2 additions & 2 deletions apps/landing/src/app/[framework]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import '@useupup/interactive-example/styles'
import { InteractiveExampleClient } from '@/components/InteractiveExampleClient'
import DeferredInteractiveExample from '@/components/DeferredInteractiveExample'
import { interactiveExampleEnvProps } from '@/lib/interactive-example-props'
import { FRAMEWORK_IDS, getFramework } from '@/lib/frameworks'
import StructuredData from '@/components/StructuredData'
Expand Down Expand Up @@ -73,7 +73,7 @@ export default async function FrameworkPage({
/>
<HeroSection framework={fw.id} />
<Section id="demo">
<InteractiveExampleClient
<DeferredInteractiveExample
{...interactiveExampleEnvProps(
fw.hasImageEditor
? undefined
Expand Down
62 changes: 62 additions & 0 deletions apps/landing/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,68 @@
html {
scroll-behavior: smooth;
}

/* ── Hero entrance — CSS only, never JS ───────────────────────────
The hero copy (badge, H1, subtitle, CTAs, install box) used to enter
through framer-motion `initial="hidden"`, which means the SERVER HTML
ships it at opacity 0 and it only becomes visible once hydration has
finished. On a throttled phone that was 92% of a 10.5 s LCP, all of it
"render delay" — the text was in the HTML the whole time, just painted
transparent. A CSS animation runs off the server HTML instead, so the
first paint already carries the copy.
Stagger with an inline `--hero-delay`; keep every delay short, because
the element is invisible until its delay elapses (fill-mode `both`) and
the subtitle is the LCP element. */
@keyframes hero-rise {
from {
opacity: 0;
transform: translate3d(0, 18px, 0);
}
to {
opacity: 1;
transform: none;
}
}

/* Transform-only twin, for the H1 and the subtitle. An element at opacity 0
has not painted, so fading in the LCP text pushes LCP out by the fade's
delay AND duration — measured at ~+1.9 s on throttled mobile even though the
text was in the server HTML the whole time. These elements are therefore
painted at full opacity from the first frame and only slide into place. */
@keyframes hero-lift {
from {
transform: translate3d(0, 12px, 0);
}
to {
transform: none;
}
}

.hero-rise {
animation: hero-rise 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation-delay: var(--hero-delay, 0s);
}

.hero-lift {
animation: hero-lift 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation-delay: var(--hero-delay, 0s);
}

/* Per-word variant for the H1 (the old BlurText). Words are plain text nodes
either way — this only slides them in. */
.hero-word {
display: inline-block;
animation: hero-lift 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation-delay: var(--hero-delay, 0s);
}

@media (prefers-reduced-motion: reduce) {
.hero-rise,
.hero-lift,
.hero-word {
animation: none;
}
}
body {
background: var(--bg-base);
color: var(--fg-base);
Expand Down
6 changes: 5 additions & 1 deletion apps/landing/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,13 @@ export default function RootLayout({
</Script>
{process.env.NODE_ENV === 'production' && (
<>
{/* lazyOnload: Hotjar is session-recording, never
needed for the page to work, and on a throttled
phone its loader competed with hydration for the
main thread. */}
<Script
defer
strategy="afterInteractive"
strategy="lazyOnload"
dangerouslySetInnerHTML={{
__html: `
(function(h,o,t,j,a,r){
Expand Down
4 changes: 2 additions & 2 deletions apps/landing/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import '@useupup/interactive-example/styles'
import { InteractiveExampleClient } from '@/components/InteractiveExampleClient'
import DeferredInteractiveExample from '@/components/DeferredInteractiveExample'
import { interactiveExampleEnvProps } from '@/lib/interactive-example-props'
import HomepageFeatures from '@/components/HomepageFeatures'
import FeedbackSection from '@/components/FeedbackSection'
Expand All @@ -26,7 +26,7 @@ export default function Home() {
<StructuredData />
<HeroSection />
<Section id="demo">
<InteractiveExampleClient {...interactiveExampleEnvProps()} />
<DeferredInteractiveExample {...interactiveExampleEnvProps()} />
</Section>
<HomepageFeatures />
<StackBlitzDemoSection />
Expand Down
76 changes: 76 additions & 0 deletions apps/landing/src/components/DeferredInteractiveExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import type { InteractiveExampleProps } from '@useupup/interactive-example'

// The live uploader demo is the single heaviest thing on the marketing pages:
// `@useupup/interactive-example` drags in @useupup/react, @useupup/core,
// @mastra/client-js and pako, and all of it used to sit in the initial chunk set
// of `/` and every `/[framework]/` page even though the section starts below the
// fold. It carries no indexable copy (the surrounding section headings are
// server-rendered), so it loads client-side only, and only once the visitor is
// near it.
const InteractiveExampleClient = dynamic(
() =>
import('@/components/InteractiveExampleClient').then(
m => m.InteractiveExampleClient,
),
{ ssr: false, loading: () => <DemoPlaceholder /> },
)

// Reserves the demo's box so mounting it does not shift the page.
function DemoPlaceholder() {
return <div className="min-h-[520px] w-full" aria-hidden="true" />
}

/**
* Viewport gate around the demo: mount it the first time it comes within 800px
* of the viewport, and keep it mounted afterwards. An IntersectionObserver is
* used directly rather than framer's `useInView` so this file pulls in no
* animation runtime of its own.
*
* The lead distance is deliberately generous. The mounted demo is much taller
* than the reserved placeholder (1564px vs 520px at a 412px viewport), so the
* growth has to happen while the section is still off-screen — otherwise every
* visitor scrolling toward it would watch the page below jump.
*/
export default function DeferredInteractiveExample(
props: InteractiveExampleProps,
) {
const ref = useRef<HTMLDivElement | null>(null)
const [mounted, setMounted] = useState(false)

useEffect(() => {
if (mounted) return
const el = ref.current
if (!el) return
// No IntersectionObserver (very old browser, or a test shim): render
// the demo rather than hide it.
if (typeof IntersectionObserver === 'undefined') {
setMounted(true)
return
}
const observer = new IntersectionObserver(
entries => {
if (entries.some(entry => entry.isIntersecting)) {
setMounted(true)
observer.disconnect()
}
},
{ rootMargin: '800px' },
)
observer.observe(el)
return () => observer.disconnect()
}, [mounted])

return (
<div ref={ref}>
{mounted ? (
<InteractiveExampleClient {...props} />
) : (
<DemoPlaceholder />
)}
</div>
)
}
Loading
Loading